create-oke 0.10.2 → 0.11.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 (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/ai-setup/apply.ts +123 -31
  4. package/src/ai-setup/catalog.ts +3 -3
  5. package/src/ai-setup/from-pref.ts +1 -1
  6. package/src/ai-setup/prompts.ts +1 -1
  7. package/src/cli.test.ts +183 -142
  8. package/src/cli.ts +190 -32
  9. package/src/create-defaults.test.ts +21 -15
  10. package/src/create-defaults.ts +33 -13
  11. package/src/customize-flow.test.ts +30 -35
  12. package/src/customize-flow.ts +68 -229
  13. package/src/drivers-catalog.ts +42 -48
  14. package/src/local-okengine.test.ts +50 -41
  15. package/src/local-okengine.ts +28 -14
  16. package/src/locales.test.ts +128 -0
  17. package/src/locales.ts +321 -0
  18. package/src/scaffold.ts +277 -33
  19. package/src/templates.ts +2 -8
  20. package/src/transform.test.ts +102 -38
  21. package/src/transform.ts +378 -132
  22. package/templates/advanced/.env.example +23 -3
  23. package/templates/advanced/README.md +1 -1
  24. package/templates/advanced/drizzle.config.ts +10 -13
  25. package/templates/advanced/oke.config.ts +21 -62
  26. package/templates/advanced/package.json +3 -1
  27. package/templates/advanced/src/app.ts +3 -10
  28. package/templates/advanced/src/core.ts +58 -0
  29. package/templates/advanced/src/db/schema.decl.ts +1 -1
  30. package/templates/advanced/src/db/seed/index.ts +2 -2
  31. package/templates/advanced/src/flows/notes/index.ts +3 -15
  32. package/templates/advanced/src/locales/index.ts +6 -0
  33. package/templates/advanced/tests/advanced.test.ts +1 -1
  34. package/templates/advanced/tsconfig.json +3 -0
  35. package/templates/standard/.env.example +23 -3
  36. package/templates/standard/README.md +1 -1
  37. package/templates/standard/drizzle.config.ts +10 -13
  38. package/templates/standard/oke.config.ts +18 -60
  39. package/templates/standard/package.json +3 -1
  40. package/templates/standard/src/app.ts +3 -10
  41. package/templates/standard/src/core.ts +55 -0
  42. package/templates/standard/src/db/schema.decl.ts +1 -1
  43. package/templates/standard/src/db/seed/index.ts +2 -2
  44. package/templates/standard/src/flows/notes/index.ts +3 -12
  45. package/templates/standard/src/locales/index.ts +6 -0
  46. package/templates/standard/tests/standard.test.ts +1 -1
  47. package/templates/standard/tsconfig.json +3 -0
  48. package/templates/advanced/src/core/channels.ts +0 -13
  49. package/templates/advanced/src/core/gates.ts +0 -12
  50. package/templates/advanced/src/core/index.ts +0 -12
  51. package/templates/advanced/src/core/store.ts +0 -8
  52. package/templates/advanced/src/core/vault.ts +0 -7
  53. package/templates/advanced/src/locales/ar.ts +0 -18
  54. package/templates/standard/src/core/channels.ts +0 -13
  55. package/templates/standard/src/core/gates.ts +0 -12
  56. package/templates/standard/src/core/index.ts +0 -12
  57. package/templates/standard/src/core/store.ts +0 -5
  58. package/templates/standard/src/core/vault.ts +0 -7
  59. package/templates/standard/src/locales/ar.ts +0 -18
@@ -4,6 +4,9 @@
4
4
  * Never lists ids that fail loud at boot (signal postgres/nats,
5
5
  * ai bedrock/vertex). Clock + journal `postgres` are real (SKIP LOCKED
6
6
  * CronStore / durable-run journal).
7
+ *
8
+ * Docker-first: `dev`/`prod` share compose-backed protocols; `test` uses
9
+ * PGLite for SQL and memory drivers elsewhere. SQLite is not offered.
7
10
  */
8
11
 
9
12
  import type { CreateDefaults, CreateProfile, EnvDriverPins } from "./create-defaults.ts";
@@ -15,31 +18,21 @@ export type DriverChoice = {
15
18
  readonly label: string;
16
19
  };
17
20
 
18
- /** Standard template docker/prod pins (unchanged for local-only profile). */
19
- export const TEMPLATE_DOCKER_PROD = {
21
+ /** Template `dev`/`prod` pins (Docker Compose runtime). */
22
+ export const TEMPLATE_DEV = {
20
23
  sql: "postgres",
21
24
  kv: "redis",
22
25
  files: "s3",
23
26
  signal: "redis",
24
27
  clock: "postgres",
25
- vault: "openbao",
28
+ /** Built-in encrypted-at-rest store — SQL-backed, so no extra container. */
29
+ vault: "vault",
26
30
  email: "smtp",
27
31
  } as const;
28
32
 
29
- /** Standard template local defaults. */
30
- export const TEMPLATE_LOCAL = {
31
- sql: "sqlite",
32
- kv: "memory",
33
- files: "fs",
34
- signal: "memory",
35
- clock: "memory",
36
- vault: "env",
37
- email: "console",
38
- } as const;
39
-
40
- /** Test-column defaults (always applied). */
33
+ /** Test-column defaults (PGLite for SQL — not a customize choice). */
41
34
  export const TEMPLATE_TEST = {
42
- sql: "memory",
35
+ sql: "pglite",
43
36
  kv: "memory",
44
37
  files: "memory",
45
38
  signal: "memory",
@@ -48,11 +41,11 @@ export const TEMPLATE_TEST = {
48
41
  email: "console",
49
42
  } as const;
50
43
 
44
+ /** @deprecated Use {@link TEMPLATE_DEV}. */
45
+ export const TEMPLATE_DOCKER_PROD = TEMPLATE_DEV;
46
+
51
47
  export const SQL_CHOICES: readonly DriverChoice[] = [
52
- { value: "sqlite", label: "sqlite" },
53
48
  { value: "postgres", label: "postgres" },
54
- { value: "libsql", label: "libsql" },
55
- { value: "pglite", label: "pglite" },
56
49
  { value: "memory", label: "memory" },
57
50
  ];
58
51
 
@@ -71,7 +64,6 @@ export const INDEX_CHOICES: readonly DriverChoice[] = [
71
64
  { value: "none", label: "none" },
72
65
  { value: "memory", label: "memory" },
73
66
  { value: "pgvector", label: "pgvector" },
74
- { value: "libsql", label: "libsql" },
75
67
  { value: "meilisearch", label: "meilisearch" },
76
68
  ];
77
69
 
@@ -93,11 +85,17 @@ export const JOURNAL_CHOICES: readonly DriverChoice[] = [
93
85
  { value: "postgres", label: "postgres" },
94
86
  ];
95
87
 
88
+ /**
89
+ * Vault backends. `vault` is okengine's own encrypted-at-rest store: it lives
90
+ * in the app's Postgres, so it needs no extra container, and it is the only
91
+ * backend with `oke vault init` / `seal` / `audit`. `managed` reads from a
92
+ * provider secret store (e.g. AWS Secrets Manager).
93
+ */
96
94
  export const VAULT_CHOICES: readonly DriverChoice[] = [
97
- { value: "env", label: "env" },
98
- { value: "openbao", label: "openbao" },
99
- { value: "managed", label: "managed" },
100
- { value: "memory", label: "memory" },
95
+ { value: "env", label: "env (dotenv layers only)" },
96
+ { value: "vault", label: "vault (built-in, encrypted at rest — recommended)" },
97
+ { value: "managed", label: "managed (KMS / provider secret store)" },
98
+ { value: "memory", label: "memory (test)" },
101
99
  ];
102
100
 
103
101
  /** Email driver ids for `drivers.channel.email` (SMS/WhatsApp/push are other mediums). */
@@ -140,31 +138,30 @@ export const AI_PROVIDERS = [
140
138
  export type AiProviderId = (typeof AI_PROVIDERS)[number]["value"];
141
139
 
142
140
  /**
143
- * Build env pins for local-only: user local + template docker/prod + test.
141
+ * Build `{ dev, test, prod }` pins (prod mirrors dev).
144
142
  *
145
- * @param local - Chosen local driver
146
- * @param dockerProd - Template docker/prod pin for this facet
143
+ * @param dev - Dev (Compose) driver
147
144
  * @param test - Test pin
145
+ * @param prod - Prod pin (defaults to `dev`)
148
146
  */
149
- export function pinsLocalOnly(local: string, dockerProd: string, test: string): EnvDriverPins {
150
- return { local, docker: dockerProd, test, prod: dockerProd };
147
+ export function pinsEnv(dev: string, test: string, prod: string = dev): EnvDriverPins {
148
+ return { dev, test, prod };
151
149
  }
152
150
 
153
151
  /**
154
- * Build env pins when the user chose both columns (docker-ready).
152
+ * Docker-ready pins `dev`/`prod` share the chosen driver; `test` is separate.
155
153
  *
156
- * @param local - Local driver
157
- * @param docker - Docker driver (also copied to prod)
154
+ * @param dev - Dev/prod driver
158
155
  * @param test - Test pin
159
156
  */
160
- export function pinsDockerReady(local: string, docker: string, test: string): EnvDriverPins {
161
- return { local, docker, test, prod: docker };
157
+ export function pinsDockerReady(dev: string, test: string): EnvDriverPins {
158
+ return pinsEnv(dev, test, dev);
162
159
  }
163
160
 
164
161
  /**
165
162
  * Default recommended create-defaults (matches template driver maps).
166
163
  *
167
- * @param profile - Profile assumption
164
+ * @param profile - Always docker-ready (kept for call-site compat)
168
165
  * @param template - Starter id
169
166
  */
170
167
  export function recommendedDefaults(
@@ -172,9 +169,9 @@ export function recommendedDefaults(
172
169
  template: TemplateId = "standard",
173
170
  ): CreateDefaults {
174
171
  const store = {
175
- sql: pinsLocalOnly(TEMPLATE_LOCAL.sql, TEMPLATE_DOCKER_PROD.sql, TEMPLATE_TEST.sql),
176
- kv: pinsLocalOnly(TEMPLATE_LOCAL.kv, TEMPLATE_DOCKER_PROD.kv, TEMPLATE_TEST.kv),
177
- files: pinsLocalOnly(TEMPLATE_LOCAL.files, TEMPLATE_DOCKER_PROD.files, TEMPLATE_TEST.files),
172
+ sql: pinsDockerReady(TEMPLATE_DEV.sql, TEMPLATE_TEST.sql),
173
+ kv: pinsDockerReady(TEMPLATE_DEV.kv, TEMPLATE_TEST.kv),
174
+ files: pinsDockerReady(TEMPLATE_DEV.files, TEMPLATE_TEST.files),
178
175
  index: null as EnvDriverPins | null,
179
176
  };
180
177
  return {
@@ -183,19 +180,17 @@ export function recommendedDefaults(
183
180
  profile,
184
181
  drivers: {
185
182
  store,
186
- signal: pinsLocalOnly(
187
- TEMPLATE_LOCAL.signal,
188
- TEMPLATE_DOCKER_PROD.signal,
189
- TEMPLATE_TEST.signal,
190
- ),
191
- clock: pinsLocalOnly(TEMPLATE_LOCAL.clock, TEMPLATE_DOCKER_PROD.clock, TEMPLATE_TEST.clock),
192
- vault: pinsLocalOnly(TEMPLATE_LOCAL.vault, TEMPLATE_DOCKER_PROD.vault, TEMPLATE_TEST.vault),
183
+ signal: pinsDockerReady(TEMPLATE_DEV.signal, TEMPLATE_TEST.signal),
184
+ clock: pinsDockerReady(TEMPLATE_DEV.clock, TEMPLATE_TEST.clock),
185
+ vault: pinsDockerReady(TEMPLATE_DEV.vault, TEMPLATE_TEST.vault),
193
186
  channel: {
194
- email: pinsLocalOnly(TEMPLATE_LOCAL.email, TEMPLATE_DOCKER_PROD.email, TEMPLATE_TEST.email),
187
+ email: pinsDockerReady(TEMPLATE_DEV.email, TEMPLATE_TEST.email),
195
188
  },
196
189
  ai: null,
197
190
  },
198
191
  ai: { enabled: false, provider: null, driver: null },
192
+ locales: [],
193
+ pgdog: false,
199
194
  updatedAt: new Date().toISOString(),
200
195
  };
201
196
  }
@@ -212,7 +207,7 @@ export type CustomizeFacetId =
212
207
  | "email";
213
208
 
214
209
  /**
215
- * Facets shown for a template (AI is asked after both env passes).
210
+ * Facets shown for a template (AI is asked after the env pass).
216
211
  *
217
212
  * @param template - Starter id
218
213
  */
@@ -238,7 +233,6 @@ export const DEFAULT_IMAGES: Readonly<Record<string, string>> = {
238
233
  "store.kv": "redis:8-alpine",
239
234
  "store.files": "rustfs/rustfs:1.0.0-beta.11",
240
235
  "channel.email": "axllent/mailpit:v1.22.3",
241
- vault: "openbao/openbao:2.6.1",
242
236
  "store.index": "getmeili/meilisearch:v1.37",
243
237
  ai: LLAMA_CPP_IMAGE,
244
238
  };
@@ -10,50 +10,59 @@ import { resolveLocalOkengineRoot } from "./templates.ts";
10
10
  import { resolveOkengineDependency } from "./transform.ts";
11
11
 
12
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;
13
+ // Cold `bun install` into ~/.oke can exceed the default 5s under full-suite load.
14
+ test(
15
+ "stages publish-shaped package without workspaces or devDependencies",
16
+ () => {
17
+ const root = resolveLocalOkengineRoot();
18
+ expect(root).not.toBeNull();
19
+ if (!root) return;
17
20
 
18
- const dep = materializeLocalOkengineDependency(root);
19
- const stage = localOkengineStageDir();
20
- expect(dep).toBe(`file:${stage}`);
21
+ const dep = materializeLocalOkengineDependency(root);
22
+ const stage = localOkengineStageDir();
23
+ expect(dep).toBe(`file:${stage}`);
21
24
 
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");
25
+ const pkg = JSON.parse(readFileSync(join(stage, "package.json"), "utf8")) as {
26
+ name: string;
27
+ dependencies?: Record<string, string>;
28
+ devDependencies?: unknown;
29
+ workspaces?: unknown;
30
+ peerDependencies?: Record<string, string>;
31
+ exports?: Record<string, string>;
32
+ };
33
+ expect(pkg.name).toBe("okengine");
34
+ expect(pkg.devDependencies).toBeUndefined();
35
+ expect(pkg.workspaces).toBeUndefined();
36
+ expect(pkg.peerDependencies).toBeUndefined();
37
+ expect(pkg.dependencies?.["oxc-parser"]).toBeDefined();
38
+ // Peers folded into dependencies so the out-of-tree stage can resolve them.
39
+ expect(pkg.dependencies?.["drizzle-orm"]).toBe("1.0.0-rc.4");
40
+ expect(pkg.dependencies?.["drizzle-kit"]).toBe("1.0.0-rc.4");
41
+ expect(pkg.dependencies?.["zod"]).toBeDefined();
42
+ expect(pkg.exports?.["./config"]).toBe("./src/config/index.ts");
40
43
 
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
- });
44
+ // Real copies — Bun file: install drops directory symlinks.
45
+ const src = join(stage, "src");
46
+ expect(existsSync(src)).toBe(true);
47
+ expect(lstatSync(src).isSymbolicLink()).toBe(false);
48
+ expect(existsSync(join(stage, "src/config/index.ts"))).toBe(true);
49
+ expect(existsSync(join(stage, "node_modules", "zod"))).toBe(true);
50
+ expect(existsSync(join(stage, "node_modules", "drizzle-kit", "cli.mjs"))).toBe(true);
51
+ },
52
+ { timeout: 60_000 },
53
+ );
49
54
 
50
- test("resolveOkengineDependency uses the staged file: path locally", () => {
51
- const root = resolveLocalOkengineRoot();
52
- expect(root).not.toBeNull();
53
- if (!root) return;
55
+ test(
56
+ "resolveOkengineDependency uses the staged file: path locally",
57
+ () => {
58
+ const root = resolveLocalOkengineRoot();
59
+ expect(root).not.toBeNull();
60
+ if (!root) return;
54
61
 
55
- const dep = resolveOkengineDependency(root);
56
- expect(dep).toBe(`file:${localOkengineStageDir()}`);
57
- expect(dep).not.toBe(`file:${root}`);
58
- });
62
+ const dep = resolveOkengineDependency(root);
63
+ expect(dep).toBe(`file:${localOkengineStageDir()}`);
64
+ expect(dep).not.toBe(`file:${root}`);
65
+ },
66
+ { timeout: 60_000 },
67
+ );
59
68
  });
@@ -95,9 +95,19 @@ export function materializeLocalOkengineDependency(localOkengineRoot: string): s
95
95
  };
96
96
 
97
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");
98
+ const pkgBody = `${JSON.stringify(consumer, null, 2)}\n`;
99
+ const stagePkgPath = join(stage, "package.json");
100
+ const stageReady =
101
+ existsSync(join(stage, "node_modules", "zod")) &&
102
+ existsSync(join(stage, "node_modules", "drizzle-kit", "cli.mjs")) &&
103
+ existsSync(stagePkgPath) &&
104
+ readFileSync(stagePkgPath, "utf8") === pkgBody;
105
+
106
+ if (!stageReady) {
107
+ rmSync(stage, { recursive: true, force: true });
108
+ mkdirSync(stage, { recursive: true });
109
+ writeFileSync(stagePkgPath, pkgBody, "utf8");
110
+ }
101
111
 
102
112
  const entries =
103
113
  consumer.files && consumer.files.length > 0
@@ -109,20 +119,24 @@ export function materializeLocalOkengineDependency(localOkengineRoot: string): s
109
119
  if (!existsSync(from)) continue;
110
120
  const to = join(stage, rel);
111
121
  mkdirSync(dirname(to), { recursive: true });
122
+ // Refresh publish files every call so scaffold always sees current source.
123
+ rmSync(to, { recursive: true, force: true });
112
124
  cpSync(from, to, { recursive: true, dereference: true });
113
125
  }
114
126
 
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
- );
127
+ if (!stageReady) {
128
+ const install = Bun.spawnSync({
129
+ cmd: ["bun", "install"],
130
+ cwd: stage,
131
+ stdout: "pipe",
132
+ stderr: "pipe",
133
+ });
134
+ if (install.exitCode !== 0) {
135
+ const err = new TextDecoder().decode(install.stderr).trim();
136
+ throw new Error(
137
+ `create-oke: bun install failed in local okengine stage${err ? `\n${err}` : ""}`,
138
+ );
139
+ }
126
140
  }
127
141
 
128
142
  return `file:${stage}`;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Locale parsing + scaffold apply helpers.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import {
10
+ applyLocalesToProject,
11
+ formatI18nConfig,
12
+ formatLocalesIndex,
13
+ parseExtraLocales,
14
+ replaceI18nConfig,
15
+ stripLegacyAppLocaleImports,
16
+ } from "./locales.ts";
17
+
18
+ describe("parseExtraLocales", () => {
19
+ test("parses ar and ar,fr", () => {
20
+ expect(parseExtraLocales("ar")).toEqual(["ar"]);
21
+ expect(parseExtraLocales("ar,fr")).toEqual(["ar", "fr"]);
22
+ expect(parseExtraLocales(" AR ; fr ")).toEqual(["ar", "fr"]);
23
+ });
24
+
25
+ test("drops en and duplicates", () => {
26
+ expect(parseExtraLocales("en,ar,en,ar")).toEqual(["ar"]);
27
+ });
28
+
29
+ test("rejects empty / invalid", () => {
30
+ expect(parseExtraLocales("")).toBeNull();
31
+ expect(parseExtraLocales("1ar")).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe("formatI18nConfig", () => {
36
+ test("English-only omits dir", () => {
37
+ expect(formatI18nConfig(["en"])).toBe('i18n: { locales: ["en"], default: "en" }');
38
+ });
39
+
40
+ test("Arabic adds rtl dir", () => {
41
+ expect(formatI18nConfig(["en", "ar"])).toBe(
42
+ 'i18n: { locales: ["en", "ar"], default: "en", dir: { "ar": "rtl" } }',
43
+ );
44
+ });
45
+ });
46
+
47
+ describe("formatLocalesIndex", () => {
48
+ test("lists en then extras as relative imports", () => {
49
+ const src = formatLocalesIndex(["en", "ar", "fr"]);
50
+ expect(src).toContain('import "./en";');
51
+ expect(src).toContain('import "./ar";');
52
+ expect(src).toContain('import "./fr";');
53
+ expect(src.match(/import "\.\//g)?.length).toBe(3);
54
+ });
55
+ });
56
+
57
+ describe("stripLegacyAppLocaleImports", () => {
58
+ test("removes @/ and ./ per-locale imports", () => {
59
+ const src = `import "@/core";
60
+ import "@/locales/en";
61
+ import "./locales/ar";
62
+
63
+ import { oke } from "okengine/http";
64
+ `;
65
+ const next = stripLegacyAppLocaleImports(src);
66
+ expect(next).not.toContain('import "@/locales/');
67
+ expect(next).not.toContain('import "./locales/');
68
+ expect(next).toContain('import "@/core";');
69
+ expect(next).toContain('import { oke } from "okengine/http";');
70
+ });
71
+ });
72
+
73
+ describe("replaceI18nConfig", () => {
74
+ test("replaces nested dir block", () => {
75
+ const src = `export default defineConfig({
76
+ i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
77
+ });
78
+ `;
79
+ const next = replaceI18nConfig(src, ["en"]);
80
+ expect(next).toContain('i18n: { locales: ["en"], default: "en" }');
81
+ expect(next).not.toContain("dir:");
82
+ });
83
+ });
84
+
85
+ describe("applyLocalesToProject", () => {
86
+ test("adds ar + fr files and updates locales index", () => {
87
+ const dir = mkdtempSync(join(tmpdir(), "oke-locales-"));
88
+ try {
89
+ mkdirSync(join(dir, "src/locales"), { recursive: true });
90
+ mkdirSync(join(dir, "src"), { recursive: true });
91
+ writeFileSync(
92
+ join(dir, "oke.config.ts"),
93
+ `export default defineConfig({\n i18n: { locales: ["en"], default: "en" },\n});\n`,
94
+ "utf8",
95
+ );
96
+ writeFileSync(
97
+ join(dir, "src/app.ts"),
98
+ `import "@/core";\n\nexport const app = {};\n`,
99
+ "utf8",
100
+ );
101
+ writeFileSync(
102
+ join(dir, "src/core.ts"),
103
+ `export const noteCreatedMail = mail.template("note-created", {\n locales: ["en"],\n});\n`,
104
+ "utf8",
105
+ );
106
+ writeFileSync(join(dir, "src/locales/en.ts"), "export const en = {};\n", "utf8");
107
+ writeFileSync(join(dir, "src/locales/index.ts"), formatLocalesIndex(["en"]), "utf8");
108
+
109
+ applyLocalesToProject(dir, ["ar", "fr"]);
110
+
111
+ expect(readFileSync(join(dir, "oke.config.ts"), "utf8")).toContain('"ar"');
112
+ expect(readFileSync(join(dir, "oke.config.ts"), "utf8")).toContain('"fr"');
113
+ expect(readFileSync(join(dir, "oke.config.ts"), "utf8")).toContain('"ar": "rtl"');
114
+ const index = readFileSync(join(dir, "src/locales/index.ts"), "utf8");
115
+ expect(index).toContain('import "./en";');
116
+ expect(index).toContain('import "./ar";');
117
+ expect(index).toContain('import "./fr";');
118
+ expect(readFileSync(join(dir, "src/app.ts"), "utf8")).not.toContain('import "@/locales/');
119
+ expect(readFileSync(join(dir, "src/locales/ar.ts"), "utf8")).toContain("غير موجود");
120
+ expect(readFileSync(join(dir, "src/locales/fr.ts"), "utf8")).toContain("TODO: translate");
121
+ expect(readFileSync(join(dir, "src/core.ts"), "utf8")).toContain(
122
+ 'locales: ["en", "ar", "fr"]',
123
+ );
124
+ } finally {
125
+ rmSync(dir, { recursive: true, force: true });
126
+ }
127
+ });
128
+ });