create-oke 0.9.1 → 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
@@ -18,6 +18,10 @@ import {
18
18
  TEMPLATE_TEST,
19
19
  VAULT_CHOICES,
20
20
  AI_PROVIDERS,
21
+ LLAMA_CPP_IMAGE,
22
+ OLLAMA_IMAGE,
23
+ SGLANG_IMAGE,
24
+ VLLM_IMAGE,
21
25
  aiDriverForProvider,
22
26
  customizeFacetsFor,
23
27
  pinsDockerReady,
@@ -43,12 +47,12 @@ export type SidePins = {
43
47
  sql?: string;
44
48
  kv?: string;
45
49
  files?: string;
50
+ /** Driver id, or `"none"` / unset for no store.index. */
46
51
  index?: string | null;
47
52
  signal?: string;
48
53
  clock?: string;
49
54
  vault?: string;
50
55
  email?: string;
51
- enableIndex?: boolean;
52
56
  };
53
57
 
54
58
  /**
@@ -62,6 +66,15 @@ export function pinsFromSides(local: string, docker: string, test: string): EnvD
62
66
  return { local, docker, test, prod: docker };
63
67
  }
64
68
 
69
+ /**
70
+ * Whether a side pin enables store.index.
71
+ *
72
+ * @param value - Picked driver or none
73
+ */
74
+ function indexEnabled(value: string | null | undefined): value is string {
75
+ return typeof value === "string" && value !== "none";
76
+ }
77
+
65
78
  /**
66
79
  * Fill missing side from template defaults.
67
80
  *
@@ -92,17 +105,13 @@ export function assembleDriverDefaults(
92
105
  dockerSrc.files ?? TEMPLATE_DOCKER_PROD.files,
93
106
  TEMPLATE_TEST.files,
94
107
  );
95
- const enableIndex = Boolean(
96
- (side === "local" ? picked.enableIndex : other?.enableIndex) ||
97
- (side === "docker" ? picked.enableIndex : other?.enableIndex),
98
- );
99
108
  const indexLocal = localSrc.index;
100
109
  const indexDocker = dockerSrc.index;
101
110
  const index =
102
- enableIndex || indexLocal != null || indexDocker != null
111
+ indexEnabled(indexLocal) || indexEnabled(indexDocker)
103
112
  ? pinsFromSides(
104
- typeof indexLocal === "string" ? indexLocal : "memory",
105
- typeof indexDocker === "string" ? indexDocker : "meilisearch",
113
+ indexEnabled(indexLocal) ? indexLocal : "memory",
114
+ indexEnabled(indexDocker) ? indexDocker : "meilisearch",
106
115
  "memory",
107
116
  )
108
117
  : null;
@@ -135,6 +144,55 @@ export function assembleDriverDefaults(
135
144
  };
136
145
  }
137
146
 
147
+ /**
148
+ * Balanced llama.cpp defaults for "AI setup → Recommended" (no quiz).
149
+ * Lightest local footprint; curated Docker Hub `ai/` model via docker-repo.
150
+ */
151
+ export function recommendedAiApply(): AiSetupApplyInput {
152
+ return {
153
+ driver: "openai-compatible",
154
+ baseUrl: process.env.OKE_AI_URL ?? "http://127.0.0.1:8080/v1",
155
+ chatModel: "smollm2",
156
+ visionModel: null,
157
+ embedModel: null,
158
+ image: LLAMA_CPP_IMAGE,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Attach the compose image pin for a local/self-hosted AI provider menu id.
164
+ *
165
+ * @param input - Apply input from prompts
166
+ * @param provider - Menu provider id
167
+ */
168
+ export function withLocalAiImage(input: AiSetupApplyInput, provider: string): AiSetupApplyInput {
169
+ if (provider === "llama-cpp") {
170
+ return {
171
+ ...input,
172
+ image: LLAMA_CPP_IMAGE,
173
+ baseUrl: input.baseUrl ?? "http://127.0.0.1:8080/v1",
174
+ };
175
+ }
176
+ if (provider === "ollama") {
177
+ return { ...input, image: OLLAMA_IMAGE };
178
+ }
179
+ if (provider === "vllm") {
180
+ return {
181
+ ...input,
182
+ image: VLLM_IMAGE,
183
+ baseUrl: input.baseUrl ?? "http://127.0.0.1:8000/v1",
184
+ };
185
+ }
186
+ if (provider === "sglang") {
187
+ return {
188
+ ...input,
189
+ image: SGLANG_IMAGE,
190
+ baseUrl: input.baseUrl ?? "http://127.0.0.1:30000/v1",
191
+ };
192
+ }
193
+ return input;
194
+ }
195
+
138
196
  /**
139
197
  * Customize drivers for one template.
140
198
  *
@@ -148,12 +206,12 @@ export async function askCustomizeFlow(
148
206
  [
149
207
  {
150
208
  value: "local",
151
- label: "Local",
209
+ label: "Local",
152
210
  hint: "oke mode local — sqlite / memory / fs …",
153
211
  },
154
212
  {
155
213
  value: "docker",
156
- label: "Docker",
214
+ label: "Docker",
157
215
  hint: "oke mode docker — postgres / redis / s3 …",
158
216
  },
159
217
  ],
@@ -197,44 +255,82 @@ export async function askCustomizeFlow(
197
255
  let aiPref: CreateDefaults["ai"] = { enabled: false, provider: null, driver: null };
198
256
  let aiApply: AiSetupApplyInput | null = null;
199
257
 
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",
258
+ aiSetup: for (;;) {
259
+ const aiSetup = await selectWithBack(
260
+ "AI setup",
261
+ [
262
+ {
263
+ value: "recommended",
264
+ label: "Recommended",
265
+ hint: "llama.cpp · lightest local footprint",
266
+ },
267
+ {
268
+ value: "customize",
269
+ label: "Customize",
270
+ hint: "pick provider & models",
271
+ },
272
+ {
273
+ value: "off",
274
+ label: "Off",
275
+ hint: "no AI drivers",
276
+ },
277
+ ],
278
+ "recommended",
216
279
  { allowBack: true },
217
280
  );
218
- if (localProviderValue === null) return null;
219
- if (localProviderValue !== WIZARD_BACK) {
281
+ if (aiSetup === null) return null;
282
+ if (aiSetup === WIZARD_BACK) {
283
+ // Back from AI → leave AI off (same as Off) and finish customize.
284
+ break;
285
+ }
286
+ if (aiSetup === "off") break;
287
+ if (aiSetup === "recommended") {
288
+ aiPins =
289
+ primary === "local" && !alsoOther
290
+ ? pinsLocalOnly("openai-compatible", "openai-compatible", "mock")
291
+ : pinsDockerReady("openai-compatible", "openai-compatible", "mock");
292
+ aiApply = recommendedAiApply();
293
+ aiPref = aiPrefWithModels(
294
+ {
295
+ enabled: true,
296
+ provider: "llama-cpp",
297
+ driver: "openai-compatible",
298
+ },
299
+ aiApply,
300
+ );
301
+ break;
302
+ }
303
+
304
+ // customize — local provider, optional docker provider, then model wizard
305
+ const options = AI_PROVIDERS.map((p) => ({ value: p.value, label: p.label }));
306
+ providers: for (;;) {
307
+ const askDockerAi = primary === "docker" || alsoOther;
308
+ const localProviderValue = await selectWithBack(
309
+ askDockerAi || primary === "local" ? "AI Provider — local" : "AI Provider",
310
+ options,
311
+ "llama-cpp",
312
+ { allowBack: true },
313
+ );
314
+ if (localProviderValue === null) return null;
315
+ if (localProviderValue === WIZARD_BACK) continue aiSetup;
316
+
220
317
  const localProvider = localProviderValue as AiProviderId;
221
318
  const localDriver = aiDriverForProvider(localProvider);
222
319
  let dockerDriver = localDriver === "mock" ? "mock" : localDriver;
320
+ let dockerProvider: AiProviderId = localProvider;
223
321
 
224
- if (primary === "docker" || alsoOther) {
322
+ if (askDockerAi) {
225
323
  const dockerProviderValue = await selectWithBack(
226
324
  "AI Provider — docker",
227
325
  options,
228
- "ollama",
229
- {
230
- allowBack: true,
231
- },
326
+ "llama-cpp",
327
+ { allowBack: true },
232
328
  );
233
329
  if (dockerProviderValue === null) return null;
234
- if (dockerProviderValue !== WIZARD_BACK) {
235
- dockerDriver = aiDriverForProvider(dockerProviderValue);
236
- if (dockerDriver === "mock") dockerDriver = "mock";
237
- }
330
+ if (dockerProviderValue === WIZARD_BACK) continue providers;
331
+ dockerProvider = dockerProviderValue as AiProviderId;
332
+ dockerDriver = aiDriverForProvider(dockerProvider);
333
+ if (dockerDriver === "mock") dockerDriver = "mock";
238
334
  }
239
335
 
240
336
  aiPins =
@@ -250,7 +346,7 @@ export async function askCustomizeFlow(
250
346
  if (setupProvider !== "mock") {
251
347
  const picked = await askAiSetup({ provider: setupProvider });
252
348
  if (picked === null) return null;
253
- aiApply = picked;
349
+ aiApply = withLocalAiImage(picked, askDockerAi ? dockerProvider : localProvider);
254
350
  } else {
255
351
  aiApply = { driver: "mock" };
256
352
  }
@@ -263,6 +359,7 @@ export async function askCustomizeFlow(
263
359
  },
264
360
  aiApply,
265
361
  );
362
+ break aiSetup;
266
363
  }
267
364
  }
268
365
 
@@ -288,17 +385,11 @@ async function walkFacetsForSide(
288
385
  let i = 0;
289
386
  while (i < facets.length) {
290
387
  const facet = facets[i]!;
291
- if (facet === "index" && !out.enableIndex) {
292
- out.index = null;
293
- i++;
294
- continue;
295
- }
296
388
  const result = await askOneFacet(facet, side, out);
297
389
  if (result === null) return null;
298
390
  if (result === WIZARD_BACK) {
299
391
  if (i === 0) return WIZARD_BACK;
300
392
  i--;
301
- while (i > 0 && facets[i] === "index" && !out.enableIndex) i--;
302
393
  continue;
303
394
  }
304
395
  i++;
@@ -343,27 +434,11 @@ async function askOneFacet(
343
434
  state.files = v;
344
435
  return true;
345
436
  }
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
437
  case "index": {
363
438
  const v = await askSideChoice(
364
439
  `store.index — ${suffix}`,
365
440
  INDEX_CHOICES,
366
- side === "local" ? "memory" : "meilisearch",
441
+ side === "local" ? "none" : "meilisearch",
367
442
  );
368
443
  if (v === null || v === WIZARD_BACK) return v;
369
444
  state.index = v;
@@ -68,6 +68,7 @@ export const FILES_CHOICES: readonly DriverChoice[] = [
68
68
  ];
69
69
 
70
70
  export const INDEX_CHOICES: readonly DriverChoice[] = [
71
+ { value: "none", label: "none" },
71
72
  { value: "memory", label: "memory" },
72
73
  { value: "pgvector", label: "pgvector" },
73
74
  { value: "libsql", label: "libsql" },
@@ -99,24 +100,41 @@ export const VAULT_CHOICES: readonly DriverChoice[] = [
99
100
  { value: "memory", label: "memory" },
100
101
  ];
101
102
 
103
+ /** Email driver ids for `drivers.channel.email` (SMS/WhatsApp/push are other mediums). */
102
104
  export const EMAIL_CHOICES: readonly DriverChoice[] = [
103
105
  { value: "console", label: "console" },
104
106
  { value: "smtp", label: "smtp" },
105
107
  { value: "resend", label: "resend" },
106
108
  { value: "sndr", label: "sndr" },
107
- { value: "taqnyat-mail", label: "taqnyat-mail" },
109
+ /** Protocol id is `taqnyat-mail`; menu label stays short in the email facet. */
110
+ { value: "taqnyat-mail", label: "taqnyat" },
108
111
  ];
109
112
 
113
+ /**
114
+ * Pinned local-inference images — must match `src/docker/recipes/*` pins.
115
+ * Never `latest` (GGUF-parser CVE floors: llama.cpp ≥ b8146, Ollama ≥ 0.17.1).
116
+ */
117
+ export const LLAMA_CPP_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-b10290";
118
+ /** @see LLAMA_CPP_IMAGE */
119
+ export const OLLAMA_IMAGE = "ollama/ollama:0.32.6";
120
+ /** @see LLAMA_CPP_IMAGE */
121
+ export const VLLM_IMAGE = "vllm/vllm-openai:v0.26.0";
122
+ /** @see LLAMA_CPP_IMAGE */
123
+ export const SGLANG_IMAGE = "lmsysorg/sglang:v0.5.16-runtime";
124
+
110
125
  /** AI menu providers → protocol driver. */
111
126
  export const AI_PROVIDERS = [
112
- { value: "ollama", label: "◎ Ollama (Local)", driver: "ollama" },
113
- { value: "openai", label: "◈ OpenAI", driver: "openai-compatible" },
114
- { value: "anthropic", label: "◉ Anthropic", driver: "anthropic" },
115
- { value: "gemini", label: "◇ Gemini", driver: "openai-compatible" },
116
- { value: "lmstudio", label: "▣ LM Studio", driver: "openai-compatible" },
117
- { value: "openrouter", label: "⇄ OpenRouter", driver: "openai-compatible" },
118
- { value: "custom", label: "⋯ Custom OpenAI Compatible", driver: "openai-compatible" },
119
- { value: "mock", label: "◌ Mock (dev only)", driver: "mock" },
127
+ { value: "llama-cpp", label: "llama.cpp (Local)", driver: "openai-compatible" },
128
+ { value: "ollama", label: "Ollama (Local)", driver: "ollama" },
129
+ { value: "vllm", label: "vLLM (self-hosted GPU)", driver: "openai-compatible" },
130
+ { value: "sglang", label: "SGLang (self-hosted GPU)", driver: "openai-compatible" },
131
+ { value: "openai", label: "OpenAI", driver: "openai-compatible" },
132
+ { value: "anthropic", label: "Anthropic", driver: "anthropic" },
133
+ { value: "gemini", label: "Gemini", driver: "openai-compatible" },
134
+ { value: "lmstudio", label: "LM Studio", driver: "openai-compatible" },
135
+ { value: "openrouter", label: "OpenRouter", driver: "openai-compatible" },
136
+ { value: "custom", label: "Custom OpenAI Compatible", driver: "openai-compatible" },
137
+ { value: "mock", label: "Mock (dev only)", driver: "mock" },
120
138
  ] as const;
121
139
 
122
140
  export type AiProviderId = (typeof AI_PROVIDERS)[number]["value"];
@@ -187,7 +205,6 @@ export type CustomizeFacetId =
187
205
  | "sql"
188
206
  | "kv"
189
207
  | "files"
190
- | "enableIndex"
191
208
  | "index"
192
209
  | "signal"
193
210
  | "clock"
@@ -201,7 +218,7 @@ export type CustomizeFacetId =
201
218
  */
202
219
  export function customizeFacetsFor(template: TemplateId): readonly CustomizeFacetId[] {
203
220
  if (template === "standard") return ["sql"];
204
- return ["sql", "kv", "files", "enableIndex", "index", "signal", "clock", "vault", "email"];
221
+ return ["sql", "kv", "files", "index", "signal", "clock", "vault", "email"];
205
222
  }
206
223
 
207
224
  /**
@@ -223,5 +240,5 @@ export const DEFAULT_IMAGES: Readonly<Record<string, string>> = {
223
240
  "channel.email": "axllent/mailpit:v1.22.3",
224
241
  vault: "openbao/openbao:2.6.1",
225
242
  "store.index": "getmeili/meilisearch:v1.37",
226
- ai: "ollama/ollama:latest",
243
+ ai: LLAMA_CPP_IMAGE,
227
244
  };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Local okengine staging for monorepo create-oke.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { localOkengineStageDir, materializeLocalOkengineDependency } from "./local-okengine.ts";
9
+ import { resolveLocalOkengineRoot } from "./templates.ts";
10
+ import { resolveOkengineDependency } from "./transform.ts";
11
+
12
+ describe("materializeLocalOkengineDependency", () => {
13
+ test("stages publish-shaped package without workspaces or devDependencies", () => {
14
+ const root = resolveLocalOkengineRoot();
15
+ expect(root).not.toBeNull();
16
+ if (!root) return;
17
+
18
+ const dep = materializeLocalOkengineDependency(root);
19
+ const stage = localOkengineStageDir();
20
+ expect(dep).toBe(`file:${stage}`);
21
+
22
+ const pkg = JSON.parse(readFileSync(join(stage, "package.json"), "utf8")) as {
23
+ name: string;
24
+ dependencies?: Record<string, string>;
25
+ devDependencies?: unknown;
26
+ workspaces?: unknown;
27
+ peerDependencies?: Record<string, string>;
28
+ exports?: Record<string, string>;
29
+ };
30
+ expect(pkg.name).toBe("okengine");
31
+ expect(pkg.devDependencies).toBeUndefined();
32
+ expect(pkg.workspaces).toBeUndefined();
33
+ expect(pkg.peerDependencies).toBeUndefined();
34
+ expect(pkg.dependencies?.["oxc-parser"]).toBeDefined();
35
+ // Peers folded into dependencies so the out-of-tree stage can resolve them.
36
+ expect(pkg.dependencies?.["drizzle-orm"]).toBe("1.0.0-rc.4");
37
+ expect(pkg.dependencies?.["drizzle-kit"]).toBe("1.0.0-rc.4");
38
+ expect(pkg.dependencies?.["zod"]).toBeDefined();
39
+ expect(pkg.exports?.["./config"]).toBe("./src/config/index.ts");
40
+
41
+ // Real copies — Bun file: install drops directory symlinks.
42
+ const src = join(stage, "src");
43
+ expect(existsSync(src)).toBe(true);
44
+ expect(lstatSync(src).isSymbolicLink()).toBe(false);
45
+ expect(existsSync(join(stage, "src/config/index.ts"))).toBe(true);
46
+ expect(existsSync(join(stage, "node_modules", "zod"))).toBe(true);
47
+ expect(existsSync(join(stage, "node_modules", "drizzle-kit", "cli.mjs"))).toBe(true);
48
+ });
49
+
50
+ test("resolveOkengineDependency uses the staged file: path locally", () => {
51
+ const root = resolveLocalOkengineRoot();
52
+ expect(root).not.toBeNull();
53
+ if (!root) return;
54
+
55
+ const dep = resolveOkengineDependency(root);
56
+ expect(dep).toBe(`file:${localOkengineStageDir()}`);
57
+ expect(dep).not.toBe(`file:${root}`);
58
+ });
59
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Consumer-facing `file:` package for local / monorepo create-oke.
3
+ *
4
+ * Linking the monorepo root pulls `workspaces` + `devDependencies` into the
5
+ * scaffold (Console UI, drizzle-zod, …). Bun then warns that RC
6
+ * `drizzle-orm` fails drizzle-zod’s `>=0.36` peer. Stage a publish-shaped
7
+ * tree instead:
8
+ *
9
+ * 1. Copy `files` (Bun’s `file:` install drops directory symlinks).
10
+ * 2. Install dependencies **in the stage** — Bun keeps `package.json` as a
11
+ * symlink into `~/.oke/...`, so the package root is outside the app and
12
+ * resolution never sees the app’s `node_modules` (peers like `zod` must
13
+ * live next to the stage too).
14
+ */
15
+
16
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join, resolve } from "node:path";
19
+
20
+ /** Fields kept on the staged consumer package.json. */
21
+ type ConsumerOkenginePackageJson = {
22
+ readonly name: string;
23
+ readonly version: string;
24
+ readonly description?: string;
25
+ readonly license?: string;
26
+ readonly repository?: unknown;
27
+ readonly bin?: unknown;
28
+ readonly files?: readonly string[];
29
+ readonly type?: string;
30
+ readonly sideEffects?: boolean | string[];
31
+ readonly exports?: unknown;
32
+ readonly dependencies?: Record<string, string>;
33
+ readonly peerDependencies?: Record<string, string>;
34
+ readonly peerDependenciesMeta?: Record<string, unknown>;
35
+ readonly engines?: unknown;
36
+ };
37
+
38
+ /**
39
+ * Directory under `~/.oke` where the consumer-facing local package is staged.
40
+ *
41
+ * @returns Absolute stage root
42
+ */
43
+ export function localOkengineStageDir(): string {
44
+ return join(homedir(), ".oke", "create-oke", "okengine");
45
+ }
46
+
47
+ /**
48
+ * Build (or refresh) a publish-shaped okengine package and return its
49
+ * installable `file:` dependency string.
50
+ *
51
+ * @param localOkengineRoot - Absolute monorepo / package root
52
+ */
53
+ export function materializeLocalOkengineDependency(localOkengineRoot: string): string {
54
+ const root = resolve(localOkengineRoot);
55
+ const pkgPath = join(root, "package.json");
56
+ if (!existsSync(pkgPath)) {
57
+ throw new Error(`create-oke: okengine package.json missing at ${pkgPath}`);
58
+ }
59
+
60
+ const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
61
+ if (raw["name"] !== "okengine") {
62
+ throw new Error(`create-oke: expected package name "okengine" at ${pkgPath}`);
63
+ }
64
+
65
+ const deps = isStringRecord(raw["dependencies"]) ? { ...raw["dependencies"] } : {};
66
+ const peers = isStringRecord(raw["peerDependencies"]) ? raw["peerDependencies"] : {};
67
+ const pins = {
68
+ ...(isStringRecord(raw["devDependencies"]) ? raw["devDependencies"] : {}),
69
+ ...deps,
70
+ };
71
+ // Peers are provided by the app at publish time; for a file: stage whose
72
+ // package root sits outside the app, install them beside the stage too.
73
+ // Prefer monorepo pin versions over peer ranges (`>=1.0.0-rc.0` can resolve
74
+ // a broken drizzle-kit channel build without `drizzle-kit/cli`).
75
+ const stagePeers = Object.fromEntries(
76
+ Object.entries(peers).map(([name, range]) => [name, pins[name] ?? range]),
77
+ );
78
+ const stageDependencies = { ...deps, ...stagePeers };
79
+
80
+ const consumer: ConsumerOkenginePackageJson = {
81
+ name: "okengine",
82
+ version: String(raw["version"] ?? "0.0.0"),
83
+ ...(typeof raw["description"] === "string" ? { description: raw["description"] } : {}),
84
+ ...(typeof raw["license"] === "string" ? { license: raw["license"] } : {}),
85
+ ...(raw["repository"] !== undefined ? { repository: raw["repository"] } : {}),
86
+ ...(raw["bin"] !== undefined ? { bin: raw["bin"] } : {}),
87
+ ...(Array.isArray(raw["files"]) ? { files: raw["files"] as string[] } : {}),
88
+ ...(typeof raw["type"] === "string" ? { type: raw["type"] } : {}),
89
+ ...(raw["sideEffects"] !== undefined
90
+ ? { sideEffects: raw["sideEffects"] as boolean | string[] }
91
+ : {}),
92
+ ...(raw["exports"] !== undefined ? { exports: raw["exports"] } : {}),
93
+ ...(Object.keys(stageDependencies).length > 0 ? { dependencies: stageDependencies } : {}),
94
+ ...(raw["engines"] !== undefined ? { engines: raw["engines"] } : {}),
95
+ };
96
+
97
+ const stage = localOkengineStageDir();
98
+ rmSync(stage, { recursive: true, force: true });
99
+ mkdirSync(stage, { recursive: true });
100
+ writeFileSync(join(stage, "package.json"), `${JSON.stringify(consumer, null, 2)}\n`, "utf8");
101
+
102
+ const entries =
103
+ consumer.files && consumer.files.length > 0
104
+ ? consumer.files
105
+ : ["src", "manifest.v1.schema.json", "AGENTS.md", "README.md"];
106
+
107
+ for (const rel of entries) {
108
+ const from = join(root, rel);
109
+ if (!existsSync(from)) continue;
110
+ const to = join(stage, rel);
111
+ mkdirSync(dirname(to), { recursive: true });
112
+ cpSync(from, to, { recursive: true, dereference: true });
113
+ }
114
+
115
+ const install = Bun.spawnSync({
116
+ cmd: ["bun", "install"],
117
+ cwd: stage,
118
+ stdout: "pipe",
119
+ stderr: "pipe",
120
+ });
121
+ if (install.exitCode !== 0) {
122
+ const err = new TextDecoder().decode(install.stderr).trim();
123
+ throw new Error(
124
+ `create-oke: bun install failed in local okengine stage${err ? `\n${err}` : ""}`,
125
+ );
126
+ }
127
+
128
+ return `file:${stage}`;
129
+ }
130
+
131
+ /**
132
+ * @param value - Unknown record candidate
133
+ */
134
+ function isStringRecord(value: unknown): value is Record<string, string> {
135
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
136
+ return Object.values(value).every((v) => typeof v === "string");
137
+ }
package/src/scaffold.ts CHANGED
@@ -175,7 +175,7 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
175
175
  /**
176
176
  * For postgres, pin `store.sql` local/docker/prod in `oke.config.ts`.
177
177
  *
178
- * The default template ships abstract `src/schema.decl.ts` — dialect is
178
+ * The default template ships abstract `src/db/schema.decl.ts` — dialect is
179
179
  * emitted from the active `store.sql` driver, so there is no hand-written
180
180
  * `sqliteTable`/`pgTable` source to rewrite. `sqlite` keeps the template
181
181
  * dual-mode config (`local: sqlite` · `docker`/`prod: postgres`) untouched.