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
package/src/scaffold.ts CHANGED
@@ -12,11 +12,18 @@ import {
12
12
  statSync,
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
- import { join, relative, resolve } from "node:path";
16
- import { resolveLocalOkengineRoot, resolveTemplateDir, type TemplateId } from "./templates.ts";
15
+ import { basename, join, relative, resolve } from "node:path";
16
+ import {
17
+ resolveLocalOkengineRoot,
18
+ resolveTemplateDir,
19
+ TEMPLATE_DEFAULT_MODE,
20
+ type TemplateId,
21
+ } from "./templates.ts";
17
22
  import { agentsMdContent } from "./agents-md.ts";
23
+ import type { CreateDefaults } from "./create-defaults.ts";
18
24
  import {
19
25
  DEFAULT_SQL_DRIVER,
26
+ applyCreateAnswers,
20
27
  sanitizeProjectName,
21
28
  shouldSkipTemplatePath,
22
29
  transformConfigForSqlDriver,
@@ -42,8 +49,11 @@ export type ScaffoldOptions = {
42
49
  /**
43
50
  * Store SQL driver — pins `oke.config.ts` `store.sql` local/docker/prod
44
51
  * when `postgres`. Default `sqlite` keeps the dual-mode config.
52
+ * Ignored when {@link createDefaults} is set.
45
53
  */
46
54
  readonly sqlDriver?: SqlDriverId;
55
+ /** Full customize / reuse answers — applied after copy. */
56
+ readonly createDefaults?: CreateDefaults;
47
57
  };
48
58
 
49
59
  /** Result of a successful scaffold. */
@@ -56,10 +66,36 @@ export type ScaffoldResult = {
56
66
  readonly okengineDependency: string;
57
67
  /** Store SQL driver applied to schema + config. */
58
68
  readonly sqlDriver: SqlDriverId;
69
+ /** Customize answers applied, if any. */
70
+ readonly createDefaults?: CreateDefaults;
59
71
  /** Relative paths written (POSIX), sorted. */
60
72
  readonly files: readonly string[];
61
73
  };
62
74
 
75
+ /**
76
+ * Why `targetDir` cannot host a new project, or `null` when it is free / empty.
77
+ *
78
+ * Used by the wizard (early name validation) and {@link scaffold}.
79
+ *
80
+ * @param targetDir - Absolute or cwd-relative destination
81
+ */
82
+ export function targetDirectoryBlockReason(targetDir: string): string | null {
83
+ const abs = resolve(targetDir);
84
+ if (!existsSync(abs)) return null;
85
+ let entries: string[];
86
+ try {
87
+ entries = readdirSync(abs);
88
+ } catch {
89
+ return `create-oke: cannot read target directory: ${abs}`;
90
+ }
91
+ if (entries.length === 0) return null;
92
+ const folder = basename(abs);
93
+ return (
94
+ `create-oke: "${folder}" already exists and is not empty (${abs}). ` +
95
+ `Pick another name, or remove that directory first.`
96
+ );
97
+ }
98
+
63
99
  /**
64
100
  * Scaffold a new okengine project from the standard template.
65
101
  *
@@ -70,14 +106,15 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
70
106
  const targetDir = resolve(options.targetDir);
71
107
  const sourceDir = resolveTemplateDir(options.source.id);
72
108
  const label = options.source.id;
73
- const sqlDriver = options.sqlDriver ?? DEFAULT_SQL_DRIVER;
109
+ const createDefaults = options.createDefaults;
110
+ const sqlDriver =
111
+ createDefaults?.drivers.store.sql.local === "postgres" || options.sqlDriver === "postgres"
112
+ ? "postgres"
113
+ : (options.sqlDriver ?? DEFAULT_SQL_DRIVER);
74
114
 
75
- if (existsSync(targetDir)) {
76
- const entries = readdirSync(targetDir);
77
- if (entries.length > 0) {
78
- throw new Error(`create-oke: target directory is not empty: ${targetDir}`);
79
- }
80
- } else {
115
+ const blocked = targetDirectoryBlockReason(targetDir);
116
+ if (blocked) throw new Error(blocked);
117
+ if (!existsSync(targetDir)) {
81
118
  mkdirSync(targetDir, { recursive: true });
82
119
  }
83
120
 
@@ -95,7 +132,11 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
95
132
  const nextPkg = transformPackageJson(sourcePkg, name, okengineDependency);
96
133
  writeFileSync(pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`, "utf8");
97
134
 
98
- applySqlDriverTransforms(targetDir, sqlDriver);
135
+ if (createDefaults) {
136
+ applyCreateDefaultsTransforms(targetDir, createDefaults);
137
+ } else {
138
+ applySqlDriverTransforms(targetDir, sqlDriver);
139
+ }
99
140
 
100
141
  if (options.writeAgentsMd !== false) {
101
142
  const agentsPath = join(targetDir, "AGENTS.md");
@@ -110,6 +151,10 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
110
151
  if (!written.includes(".env.local")) written.push(".env.local");
111
152
  }
112
153
 
154
+ // Seed `.oke/mode` so `oke dev` does not re-ask local vs docker.
155
+ writeProjectDevMode(targetDir, resolveInitialDevMode(createDefaults, options.source.id));
156
+ if (!written.includes(".oke/mode")) written.push(".oke/mode");
157
+
113
158
  written.sort();
114
159
  return {
115
160
  targetDir,
@@ -118,6 +163,7 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
118
163
  label,
119
164
  okengineDependency,
120
165
  sqlDriver,
166
+ ...(createDefaults !== undefined ? { createDefaults } : {}),
121
167
  files: written,
122
168
  };
123
169
  } catch (e) {
@@ -147,6 +193,47 @@ function applySqlDriverTransforms(targetDir: string, sqlDriver: SqlDriverId): vo
147
193
  }
148
194
  }
149
195
 
196
+ /**
197
+ * Apply full customize / reuse defaults to `oke.config.ts`.
198
+ *
199
+ * @param targetDir - Scaffolded project root
200
+ * @param defaults - Persisted create answers
201
+ */
202
+ function applyCreateDefaultsTransforms(targetDir: string, defaults: CreateDefaults): void {
203
+ const configPath = join(targetDir, "oke.config.ts");
204
+ if (!existsSync(configPath)) {
205
+ throw new Error("create-oke: scaffolded project has no oke.config.ts");
206
+ }
207
+ const next = applyCreateAnswers(readFileSync(configPath, "utf8"), defaults);
208
+ writeFileSync(configPath, next, "utf8");
209
+ }
210
+
211
+ /**
212
+ * Map create-oke profile / template → persisted `oke dev` mode.
213
+ *
214
+ * @param defaults - Customize / reuse answers, or undefined for recommended
215
+ * @param template - Starter id (recommended path uses template default mode)
216
+ */
217
+ export function resolveInitialDevMode(
218
+ defaults: CreateDefaults | undefined,
219
+ template: TemplateId = "standard",
220
+ ): "local" | "docker" {
221
+ if (defaults?.profile === "docker-ready") return "docker";
222
+ if (defaults?.profile === "local-only") return "local";
223
+ return TEMPLATE_DEFAULT_MODE[template];
224
+ }
225
+
226
+ /**
227
+ * Write project-local `.oke/mode` (same shape as `oke mode` / `oke dev`).
228
+ *
229
+ * @param targetDir - Scaffolded project root
230
+ * @param mode - Mode to save
231
+ */
232
+ function writeProjectDevMode(targetDir: string, mode: "local" | "docker"): void {
233
+ mkdirSync(join(targetDir, ".oke"), { recursive: true });
234
+ writeFileSync(join(targetDir, ".oke", "mode"), `${mode}\n`, "utf8");
235
+ }
236
+
150
237
  /**
151
238
  * Recursively copy template files, applying {@link shouldSkipTemplatePath}.
152
239
  *
package/src/templates.ts CHANGED
@@ -1,25 +1,30 @@
1
1
  /**
2
- * Standard template resolution.
3
- *
4
- * The only starter lives in this package at `template/`.
2
+ * Starter template resolution — `templates/standard` · `templates/advanced`.
5
3
  */
6
4
 
7
5
  import { existsSync, readFileSync } from "node:fs";
8
6
  import { dirname, join, resolve } from "node:path";
9
7
  import { fileURLToPath } from "node:url";
10
8
 
11
- /** The single supported starter template. */
12
- export const TEMPLATES = ["standard"] as const;
9
+ /** Supported starter templates. */
10
+ export const TEMPLATES = ["standard", "advanced"] as const;
13
11
 
14
12
  /** A known clean starter template. */
15
13
  export type TemplateId = (typeof TEMPLATES)[number];
16
14
 
17
- /** Default when `--template` is omitted (recommended project layout). */
15
+ /** Default when `--template` is omitted. */
18
16
  export const DEFAULT_TEMPLATE: TemplateId = "standard";
19
17
 
20
18
  /** One-line purpose for the starter (interactive select + help). */
21
19
  export const TEMPLATE_PURPOSES: Readonly<Record<TemplateId, string>> = {
22
- standard: "Full recommended file layout, empty scaffolding",
20
+ standard: "Notes app local-first (sqlite · memory · console email)",
21
+ advanced: "Notes app — docker-ready (postgres · redis · s3 · openbao)",
22
+ };
23
+
24
+ /** Recommended `.oke/mode` when using recommended defaults (no customize). */
25
+ export const TEMPLATE_DEFAULT_MODE: Readonly<Record<TemplateId, "local" | "docker">> = {
26
+ standard: "local",
27
+ advanced: "docker",
23
28
  };
24
29
 
25
30
  /**
@@ -43,7 +48,7 @@ export function packageRoot(): string {
43
48
  *
44
49
  * Order:
45
50
  * 1. `CREATE_OKE_TEMPLATE` env (absolute path to a template)
46
- * 2. Bundled `template/`
51
+ * 2. Bundled `templates/<id>/`
47
52
  *
48
53
  * @param template - Template id
49
54
  * @returns Absolute path to the template source tree
@@ -52,7 +57,7 @@ export function resolveTemplateDir(template: TemplateId): string {
52
57
  const envRoot = process.env["CREATE_OKE_TEMPLATE"];
53
58
  if (envRoot && existsSync(envRoot)) return resolve(envRoot);
54
59
  const pkg = packageRoot();
55
- const bundled = resolve(pkg, "template");
60
+ const bundled = resolve(pkg, "templates", template);
56
61
  if (existsSync(bundled)) return bundled;
57
62
 
58
63
  throw new Error(`create-oke: template "${template}" not found at ${bundled}`);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * create-oke config transforms — images must stay role→image pins.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { toCreateDefaults } from "./create-defaults.ts";
9
+ import { pinsDockerReady, pinsLocalOnly } from "./drivers-catalog.ts";
10
+ import { resolveTemplateDir } from "./templates.ts";
11
+ import { applyCreateAnswers, upsertAiDrivers } from "./transform.ts";
12
+ import type { EnvDriverPins } from "./create-defaults.ts";
13
+
14
+ function templateConfig(): string {
15
+ return readFileSync(join(resolveTemplateDir("standard"), "oke.config.ts"), "utf8");
16
+ }
17
+
18
+ function defaultsWithIndex(indexLocal: string, indexDocker: string) {
19
+ return toCreateDefaults({
20
+ template: "advanced",
21
+ profile: "docker-ready",
22
+ drivers: {
23
+ store: {
24
+ sql: pinsDockerReady("libsql", "postgres", "memory"),
25
+ kv: pinsLocalOnly("memory", "redis", "memory"),
26
+ files: pinsLocalOnly("fs", "s3", "memory"),
27
+ index: pinsDockerReady(indexLocal, indexDocker, "memory"),
28
+ },
29
+ signal: pinsLocalOnly("memory", "redis", "memory"),
30
+ clock: pinsLocalOnly("memory", "file", "frozen"),
31
+ vault: pinsLocalOnly("env", "openbao", "memory"),
32
+ channel: { email: pinsLocalOnly("console", "smtp", "console") },
33
+ ai: null,
34
+ },
35
+ ai: { enabled: false, provider: null, driver: null },
36
+ });
37
+ }
38
+
39
+ describe("applyCreateAnswers images", () => {
40
+ test("index: libsql does not poison images with driver pins", () => {
41
+ const next = applyCreateAnswers(templateConfig(), defaultsWithIndex("libsql", "libsql"));
42
+ expect(next).toContain('local: "libsql"');
43
+ expect(next).toMatch(/index:\s*\{\s*local: "libsql"/);
44
+ // Role pins only — never env-column keys or bare driver ids as images.
45
+ expect(next).not.toMatch(/images:\s*\{[^}]*\blocal:\s*"/s);
46
+ expect(next).not.toMatch(/images:\s*\{[^}]*\bdocker:\s*"/s);
47
+ expect(next).not.toMatch(/images:\s*\{[^}]*:\s*"libsql"/s);
48
+ expect(next).toContain('"store.sql": "postgres:18-alpine"');
49
+ });
50
+
51
+ test("index meilisearch pins store.index image without comment leakage", () => {
52
+ const next = applyCreateAnswers(
53
+ templateConfig(),
54
+ defaultsWithIndex("meilisearch", "meilisearch"),
55
+ );
56
+ expect(next).toContain('"store.index": "getmeili/meilisearch:v1.37"');
57
+ expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
58
+ });
59
+ });
60
+
61
+ describe("upsertAiDrivers", () => {
62
+ const ollamaPins: EnvDriverPins = {
63
+ local: "ollama",
64
+ docker: "ollama",
65
+ test: "mock",
66
+ prod: "ollama",
67
+ };
68
+
69
+ test("inserts drivers.ai as sibling of channel (not inside email)", () => {
70
+ const next = upsertAiDrivers(templateConfig(), ollamaPins);
71
+ expect(next).toMatch(/^ {4}ai:\s*\{/m);
72
+ expect(next).toContain('local: "ollama"');
73
+ const channel = next.match(/^ {4}channel:\s*\{[\s\S]*?\n {4}\},?\n/m)?.[0] ?? "";
74
+ expect(channel).toContain("email:");
75
+ expect(channel).not.toContain("ai:");
76
+ });
77
+
78
+ test("applyCreateAnswers with ai pins keeps top-level drivers.ai", () => {
79
+ const next = applyCreateAnswers(
80
+ templateConfig(),
81
+ toCreateDefaults({
82
+ template: "advanced",
83
+ profile: "docker-ready",
84
+ drivers: {
85
+ store: {
86
+ sql: pinsDockerReady("libsql", "postgres", "memory"),
87
+ kv: pinsLocalOnly("memory", "redis", "memory"),
88
+ files: pinsLocalOnly("fs", "s3", "memory"),
89
+ index: null,
90
+ },
91
+ signal: pinsLocalOnly("memory", "redis", "memory"),
92
+ clock: pinsLocalOnly("memory", "file", "frozen"),
93
+ vault: pinsLocalOnly("env", "openbao", "memory"),
94
+ channel: { email: pinsLocalOnly("console", "smtp", "console") },
95
+ ai: ollamaPins,
96
+ },
97
+ ai: { enabled: true, provider: "ollama", driver: "ollama" },
98
+ }),
99
+ );
100
+ expect(next).toMatch(/^ {4}ai:\s*\{/m);
101
+ expect(next).toContain('ai: "ollama/ollama:latest"');
102
+ const channel = next.match(/^ {4}channel:\s*\{[\s\S]*?\n {4}\},?\n/m)?.[0] ?? "";
103
+ expect(channel).not.toContain("ai:");
104
+ });
105
+ });
package/src/transform.ts CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  import { readFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
+ import type { CreateDefaults, EnvDriverPins } from "./create-defaults.ts";
17
+ import { DEFAULT_IMAGES } from "./drivers-catalog.ts";
16
18
  import { packageRoot } from "./templates.ts";
17
19
 
18
20
  /** SQL store drivers selectable at scaffold time. */
@@ -148,6 +150,231 @@ export function transformConfigForSqlDriver(source: string, driver: SqlDriverId)
148
150
  );
149
151
  }
150
152
 
153
+ /**
154
+ * Apply full customize answers to `oke.config.ts` source.
155
+ *
156
+ * Rewrites driver maps and syncs `images` keys for docker-backed roles.
157
+ *
158
+ * @param source - Config TypeScript source
159
+ * @param defaults - Customize / reuse payload
160
+ */
161
+ export function applyCreateAnswers(source: string, defaults: CreateDefaults): string {
162
+ let next = source;
163
+ next = replaceEnvMap(next, "sql", defaults.drivers.store.sql, { nestedUnderStore: true });
164
+ next = replaceEnvMap(next, "kv", defaults.drivers.store.kv, { nestedUnderStore: true });
165
+ next = replaceEnvMap(next, "files", defaults.drivers.store.files, { nestedUnderStore: true });
166
+
167
+ if (defaults.drivers.store.index) {
168
+ next = upsertStoreIndex(next, defaults.drivers.store.index);
169
+ }
170
+
171
+ next = replaceEnvMap(next, "signal", defaults.drivers.signal, { nestedUnderStore: false });
172
+ next = replaceEnvMap(next, "clock", defaults.drivers.clock, { nestedUnderStore: false });
173
+ next = replaceEnvMap(next, "vault", defaults.drivers.vault, { nestedUnderStore: false });
174
+ next = replaceChannelEmail(next, defaults.drivers.channel.email);
175
+
176
+ if (defaults.drivers.ai) {
177
+ next = upsertAiDrivers(next, defaults.drivers.ai);
178
+ }
179
+
180
+ next = syncImages(next, defaults);
181
+ return next;
182
+ }
183
+
184
+ /**
185
+ * Replace a top-level or store-nested env map block by key name.
186
+ *
187
+ * @param source - Config source
188
+ * @param key - Map key (`sql`, `signal`, …)
189
+ * @param pins - New pins
190
+ * @param options - Nesting
191
+ */
192
+ function replaceEnvMap(
193
+ source: string,
194
+ key: string,
195
+ pins: EnvDriverPins,
196
+ options: { readonly nestedUnderStore: boolean },
197
+ ): string {
198
+ const block = formatEnvMap(pins, options.nestedUnderStore ? 3 : 2);
199
+ // Match `key: { … }` at the driver indentation used in the template.
200
+ const re = new RegExp(`${key}:\\s*\\{[\\s\\S]*?\\n\\s*\\}`, "m");
201
+ if (!re.test(source)) {
202
+ throw new Error(`create-oke: oke.config.ts missing drivers.${key} block`);
203
+ }
204
+ return source.replace(re, `${key}: ${block}`);
205
+ }
206
+
207
+ /**
208
+ * Replace `channel.email` env map.
209
+ *
210
+ * @param source - Config source
211
+ * @param pins - Email pins
212
+ */
213
+ function replaceChannelEmail(source: string, pins: EnvDriverPins): string {
214
+ const block = formatEnvMap(pins, 3);
215
+ const re = /email:\s*\{[\s\S]*?\n\s*\}/;
216
+ if (!re.test(source)) {
217
+ throw new Error("create-oke: oke.config.ts missing channel.email block");
218
+ }
219
+ return source.replace(re, `email: ${block}`);
220
+ }
221
+
222
+ /**
223
+ * Insert or replace `store.index` under `drivers.store`.
224
+ *
225
+ * Matches only a real `index:` key at store-facet indentation — never
226
+ * `index: { … }` inside comments (e.g. the images opt-in note).
227
+ *
228
+ * @param source - Config source
229
+ * @param pins - Index pins
230
+ */
231
+ function upsertStoreIndex(source: string, pins: EnvDriverPins): string {
232
+ const block = formatEnvMap(pins, 3);
233
+ const indexRe = /^( {6})index:\s*\{[\s\S]*?\n\1\}/m;
234
+ if (indexRe.test(source)) {
235
+ return source.replace(indexRe, `$1index: ${block}`);
236
+ }
237
+ // Insert after files block inside store.
238
+ const filesRe = /(files:\s*\{[\s\S]*?\n\s*\},)/;
239
+ if (!filesRe.test(source)) {
240
+ throw new Error("create-oke: oke.config.ts missing store.files to insert index");
241
+ }
242
+ return source.replace(filesRe, `$1\n index: ${block},`);
243
+ }
244
+
245
+ /**
246
+ * Insert or replace top-level `drivers.ai` (sibling of `channel`, never nested).
247
+ *
248
+ * Indentation-anchored: only matches `ai:` / `channel:` at the drivers-body
249
+ * indent (4 spaces). The old non-greedy `channel: { … },` regex closed on
250
+ * `email`'s `},` and nested `ai` inside `channel` — hero then showed `ai: —`.
251
+ *
252
+ * @param source - Config source
253
+ * @param pins - AI pins
254
+ */
255
+ export function upsertAiDrivers(source: string, pins: EnvDriverPins): string {
256
+ const block = formatEnvMap(pins, 2);
257
+ const aiRe = /^( {4})ai:\s*\{[\s\S]*?\n\1\}/m;
258
+ if (aiRe.test(source)) {
259
+ return source.replace(aiRe, `$1ai: ${block}`);
260
+ }
261
+ const channelRe = /^( {4})channel:\s*\{[\s\S]*?\n\1\},?\n/m;
262
+ if (!channelRe.test(source)) {
263
+ throw new Error("create-oke: oke.config.ts missing channel block to insert ai");
264
+ }
265
+ return source.replace(channelRe, (match) => `${match} ai: ${block},\n`);
266
+ }
267
+
268
+ /**
269
+ * Format an env driver map literal.
270
+ *
271
+ * @param pins - Pins
272
+ * @param indentLevel - Indent depth (2 spaces each)
273
+ */
274
+ function formatEnvMap(pins: EnvDriverPins, indentLevel: number): string {
275
+ const pad = " ".repeat(indentLevel);
276
+ const close = " ".repeat(indentLevel - 1);
277
+ return `{
278
+ ${pad}local: "${pins.local}",
279
+ ${pad}docker: "${pins.docker}",
280
+ ${pad}test: "${pins.test}",
281
+ ${pad}prod: "${pins.prod}",
282
+ ${close}}`;
283
+ }
284
+
285
+ /** Env-column keys that must never appear under `images`. */
286
+ const IMAGE_ENV_COLUMNS = new Set(["local", "docker", "test", "prod"]);
287
+
288
+ /** Known compose role keys written into `images`. */
289
+ const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai)$/;
290
+
291
+ /**
292
+ * Keep `images` in sync with chosen docker drivers.
293
+ *
294
+ * Rebuilds from known roles only — never spreads poisoned env-column keys
295
+ * leaked from comments or driver maps.
296
+ *
297
+ * @param source - Config source
298
+ * @param defaults - Answers
299
+ */
300
+ function syncImages(source: string, defaults: CreateDefaults): string {
301
+ const existing = extractImages(source);
302
+ const images: Record<string, string> = {};
303
+ const d = defaults.drivers;
304
+ const pin = (role: string, fallback: string): void => {
305
+ images[role] = existing[role] ?? fallback;
306
+ };
307
+
308
+ if (
309
+ d.store.sql.docker === "postgres" ||
310
+ d.store.sql.prod === "postgres" ||
311
+ d.store.sql.docker === "pgvector" ||
312
+ d.store.sql.prod === "pgvector"
313
+ ) {
314
+ pin("store.sql", DEFAULT_IMAGES["store.sql"]!);
315
+ }
316
+ if (d.store.kv.docker === "redis" || d.signal.docker === "redis") {
317
+ pin("store.kv", DEFAULT_IMAGES["store.kv"]!);
318
+ }
319
+ if (d.store.files.docker === "s3") {
320
+ pin("store.files", DEFAULT_IMAGES["store.files"]!);
321
+ }
322
+ if (d.channel.email.docker === "smtp") {
323
+ pin("channel.email", DEFAULT_IMAGES["channel.email"]!);
324
+ }
325
+ if (d.vault.docker === "openbao") {
326
+ pin("vault", DEFAULT_IMAGES.vault!);
327
+ }
328
+ if (d.store.index?.docker === "meilisearch") {
329
+ pin("store.index", DEFAULT_IMAGES["store.index"]!);
330
+ }
331
+ if (d.ai && (d.ai.docker === "ollama" || d.ai.local === "ollama")) {
332
+ pin("ai", DEFAULT_IMAGES.ai!);
333
+ }
334
+
335
+ return replaceImagesBlock(source, images);
336
+ }
337
+
338
+ /**
339
+ * Parse role→image pins from an `images` block.
340
+ *
341
+ * Skips `//` comment lines and rejects env-column keys (`local`/`docker`/…).
342
+ *
343
+ * @param source - Config source
344
+ */
345
+ function extractImages(source: string): Record<string, string> {
346
+ const m = /images:\s*\{([\s\S]*?)\n\s*\},/.exec(source);
347
+ if (!m) return {};
348
+ const out: Record<string, string> = {};
349
+ for (const line of m[1]!.split("\n")) {
350
+ const trimmed = line.trim();
351
+ if (!trimmed || trimmed.startsWith("//")) continue;
352
+ const hit = /["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(trimmed);
353
+ if (!hit) continue;
354
+ const key = hit[1]!;
355
+ if (IMAGE_ENV_COLUMNS.has(key) || !IMAGE_ROLE_KEY.test(key)) continue;
356
+ out[key] = hit[2]!;
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /**
362
+ * @param source - Config source
363
+ * @param images - Role → image
364
+ */
365
+ function replaceImagesBlock(source: string, images: Record<string, string>): string {
366
+ const lines = Object.entries(images).map(([k, v]) => {
367
+ const key = k.includes(".") ? `"${k}"` : k;
368
+ return ` ${key}: "${v}",`;
369
+ });
370
+ const block = `images: {\n${lines.join("\n")}\n }`;
371
+ const re = /images:\s*\{[\s\S]*?\n\s*\}/;
372
+ if (!re.test(source)) {
373
+ throw new Error("create-oke: oke.config.ts missing images block");
374
+ }
375
+ return source.replace(re, block);
376
+ }
377
+
151
378
  /**
152
379
  * Sanitize a directory / package name for npm.
153
380
  *
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared Clack select helpers with optional ← Back.
3
+ */
4
+
5
+ import { isCancel, select } from "@clack/prompts";
6
+
7
+ /** Sentinel — go back one wizard step (shown as "← Back" in selects). */
8
+ export const WIZARD_BACK = "__back__" as const;
9
+ /** {@link WIZARD_BACK} type alias. */
10
+ export type WizardBack = typeof WIZARD_BACK;
11
+
12
+ /**
13
+ * Append "← Back" when allowed — pure helper for tests + {@link selectWithBack}.
14
+ *
15
+ * @param options - Choices
16
+ * @param allowBack - Whether to append Back
17
+ */
18
+ export function withBackOption(
19
+ options: readonly { value: string; label: string; hint?: string }[],
20
+ allowBack: boolean,
21
+ ): readonly { value: string; label: string; hint?: string }[] {
22
+ if (!allowBack) return options;
23
+ return [...options, { value: WIZARD_BACK, label: "← Back" }];
24
+ }
25
+
26
+ /**
27
+ * Select with an optional trailing "← Back" option.
28
+ *
29
+ * @param message - Prompt
30
+ * @param options - Choices (without back)
31
+ * @param initialValue - Initial selection
32
+ * @param opts - Whether Back is offered
33
+ */
34
+ export async function selectWithBack(
35
+ message: string,
36
+ options: readonly { value: string; label: string; hint?: string }[],
37
+ initialValue: string,
38
+ opts: { readonly allowBack: boolean },
39
+ ): Promise<string | WizardBack | null> {
40
+ const list = [...withBackOption(options, opts.allowBack)];
41
+ const value = await select({
42
+ message,
43
+ options: list,
44
+ initialValue,
45
+ });
46
+ if (isCancel(value)) return null;
47
+ const picked = String(value);
48
+ if (picked === WIZARD_BACK) return WIZARD_BACK;
49
+ return picked;
50
+ }
@@ -1,5 +1,4 @@
1
- # Environment template — copy to `.env.local` for local overrides.
2
- # create-oke copies this file to `.env.local` when you scaffold a new project.
1
+ # Environment template — create-oke copies this to `.env.local` when you scaffold.
3
2
  #
4
3
  # Resolution order (first hit wins):
5
4
  # process.env → .env.local → docker/.env.docker → vault driver → dev fallback
@@ -12,6 +11,13 @@
12
11
  # Uncomment to override compose credentials for a host-managed service.
13
12
  # Host ports under docker mode are unique per project — do not assume defaults.
14
13
 
14
+ # ── App / Console ───────────────────────────────────────────
15
+ # PORT=6530
16
+ # OKE_APP_URL=http://127.0.0.1:6530
17
+ # OKE_CONSOLE_SECRET=
18
+ # MAINTENANCE_MODE=0
19
+ # APP_WEBHOOK_SECRET=dev-webhook-secret-change-me
20
+
15
21
  # ── channel.email — Mailpit (SMTP + UI) ─────────────────────
16
22
  # OKE_CHANNEL_EMAIL_URL=smtp://127.0.0.1:1025
17
23
  # SMTP_URL=smtp://127.0.0.1:1025
@@ -25,6 +31,31 @@
25
31
  # MP_SMTP_AUTH_ACCEPT_ANY=1
26
32
  # MP_SMTP_AUTH_ALLOW_INSECURE=1
27
33
 
34
+ # ── channel.email — HTTP providers ──────────────────────────
35
+ # RESEND_API_KEY=
36
+ # SNDR_API_KEY=
37
+ # SNDR_BASE_URL=https://api.sndr.sh
38
+ # SNDR_FROM=hello@your-verified-domain.com
39
+
40
+ # ── channel.sms — Taqnyat / Msegat / Unifonic ───────────────
41
+ # TAQNYAT_BEARER_TOKEN=
42
+ # TAQNYAT_SENDER=
43
+ # MSEGAT_USERNAME=
44
+ # MSEGAT_API_KEY=
45
+ # MSEGAT_SENDER=
46
+ # UNIFONIC_APPSID=
47
+ # UNIFONIC_SENDER=
48
+
49
+ # ── channel.whatsapp / push ─────────────────────────────────
50
+ # WHATSAPP_TOKEN=
51
+ # WHATSAPP_PHONE_NUMBER_ID=
52
+ # VAPID_PUBLIC_KEY=
53
+ # VAPID_PRIVATE_KEY=
54
+ # VAPID_SUBJECT=mailto:ops@example.com
55
+ # FCM_PROJECT_ID=
56
+ # FCM_CLIENT_EMAIL=
57
+ # FCM_PRIVATE_KEY=
58
+
28
59
  # ── store.files — object storage (S3) ───────────────────────
29
60
  # OKE_STORE_FILES_URL=http://access-key:secret-key@127.0.0.1:9000/oke
30
61
  # S3_ACCESS_KEY_ID=access-key
@@ -54,3 +85,10 @@
54
85
  # Optional controls:
55
86
  # PGDATA=/var/lib/postgresql/data/pgdata
56
87
  # POSTGRES_INITDB_ARGS=--data-checksums
88
+
89
+ # ── AI ──────────────────────────────────────────────────────
90
+ # OKE_AI_DRIVER=ollama
91
+ # OKE_AI_URL=http://127.0.0.1:11434
92
+ # OKE_AI_MODEL=qwen3.5:9b
93
+ # ANTHROPIC_API_KEY=
94
+ # OPENAI_API_KEY=