create-oke 0.6.1 → 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 +1 -0
  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/cli.test.ts CHANGED
@@ -3,10 +3,21 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
7
15
  import { tmpdir } from "node:os";
8
16
  import { join } from "node:path";
9
17
  import {
18
+ WIZARD_BACK,
19
+ aiSetupProviderFor,
20
+ defaultsBranchOptions,
10
21
  formatCdPath,
11
22
  nextStepsText,
12
23
  parseArgs,
@@ -15,11 +26,26 @@ import {
15
26
  scaffoldArgsFromCli,
16
27
  shouldPrompt,
17
28
  sourceFromArgs,
29
+ withBackOption,
18
30
  type InteractiveAnswers,
19
31
  } from "./cli.ts";
20
- import { listTemplateFiles, scaffold } from "./scaffold.ts";
21
- import { DEFAULT_TEMPLATE, TEMPLATES, packageRoot, resolveTemplateDir } from "./templates.ts";
22
32
  import {
33
+ createDefaultsPath,
34
+ readCreateDefaults,
35
+ toCreateDefaults,
36
+ writeCreateDefaults,
37
+ } from "./create-defaults.ts";
38
+ import { pinsDockerReady, pinsLocalOnly, recommendedDefaults } from "./drivers-catalog.ts";
39
+ import { listTemplateFiles, scaffold, targetDirectoryBlockReason } from "./scaffold.ts";
40
+ import {
41
+ DEFAULT_TEMPLATE,
42
+ TEMPLATE_DEFAULT_MODE,
43
+ TEMPLATES,
44
+ packageRoot,
45
+ resolveTemplateDir,
46
+ } from "./templates.ts";
47
+ import {
48
+ applyCreateAnswers,
23
49
  sanitizeProjectName,
24
50
  shouldSkipTemplatePath,
25
51
  transformConfigForSqlDriver,
@@ -27,6 +53,18 @@ import {
27
53
  transformSchemaForSqlDriver,
28
54
  } from "./transform.ts";
29
55
 
56
+ describe("defaultsBranchOptions", () => {
57
+ test("hides reuse when no previous settings exist", () => {
58
+ const values = defaultsBranchOptions(false).map((o) => o.value);
59
+ expect(values).toEqual(["recommended", "customize"]);
60
+ });
61
+
62
+ test("offers reuse when previous settings exist", () => {
63
+ const values = defaultsBranchOptions(true).map((o) => o.value);
64
+ expect(values).toEqual(["recommended", "reuse", "customize"]);
65
+ });
66
+ });
67
+
30
68
  describe("parseArgs", () => {
31
69
  test("defaults template to standard", () => {
32
70
  const a = parseArgs(["my-app"]);
@@ -37,7 +75,7 @@ describe("parseArgs", () => {
37
75
 
38
76
  test("accepts --template and -t", () => {
39
77
  expect(parseArgs(["x", "--template", "standard"]).template).toBe("standard");
40
- expect(parseArgs(["x", "-t", "standard"]).template).toBe("standard");
78
+ expect(parseArgs(["x", "-t", "advanced"]).template).toBe("advanced");
41
79
  expect(parseArgs(["x", "--template=standard"]).template).toBe("standard");
42
80
  expect(parseArgs(["x", "--template", "standard"]).templateExplicit).toBe(true);
43
81
  });
@@ -100,39 +138,295 @@ describe("sourceFromArgs", () => {
100
138
  });
101
139
  });
102
140
 
141
+ function recommendedAnswers(overrides: Partial<InteractiveAnswers> = {}): InteractiveAnswers {
142
+ return {
143
+ name: "x",
144
+ choice: "standard",
145
+ installAndRun: false,
146
+ agentsMd: true,
147
+ createDefaults: undefined,
148
+ aiApply: null,
149
+ ...overrides,
150
+ };
151
+ }
152
+
103
153
  describe("scaffoldArgsFromAnswers ≡ flag-driven", () => {
104
- test("each --template path matches interactive choice", () => {
154
+ test("each --template path matches interactive choice (recommended)", () => {
105
155
  for (const id of TEMPLATES) {
106
- const answers: InteractiveAnswers = {
107
- name: "x",
108
- choice: id,
109
- installAndRun: false,
110
- agentsMd: true,
111
- };
156
+ const answers = recommendedAnswers({ choice: id });
112
157
  const fromAnswers = scaffoldArgsFromAnswers(answers);
113
158
  const fromFlags = scaffoldArgsFromCli(parseArgs(["x", "--template", id]));
114
- expect(fromAnswers).toEqual(fromFlags);
115
- expect(fromAnswers.source).toEqual({ kind: "template", id });
116
- expect(fromAnswers.sqlDriver).toBe("sqlite");
159
+ expect(fromAnswers.name).toBe(fromFlags.name);
160
+ expect(fromAnswers.targetDir).toBe(fromFlags.targetDir);
161
+ expect(fromAnswers.source).toEqual(fromFlags.source);
162
+ expect(fromAnswers.agentsMd).toBe(fromFlags.agentsMd);
163
+ expect(fromAnswers.sqlDriver).toBe(fromFlags.sqlDriver);
164
+ expect(fromAnswers.createDefaults).toBeUndefined();
117
165
  }
118
166
  });
119
167
 
120
- test("interactive never opts into --sql postgres (flag-only)", () => {
121
- const answers: InteractiveAnswers = {
122
- name: "x",
123
- choice: "standard",
124
- installAndRun: false,
125
- agentsMd: true,
126
- };
127
- expect(scaffoldArgsFromAnswers(answers).sqlDriver).toBe("sqlite");
168
+ test("interactive never opts into --sql postgres without override", () => {
169
+ expect(scaffoldArgsFromAnswers(recommendedAnswers()).sqlDriver).toBe("sqlite");
128
170
  expect(
129
171
  scaffoldArgsFromCli(parseArgs(["x", "--template", "standard", "--sql", "postgres"]))
130
172
  .sqlDriver,
131
173
  ).toBe("postgres");
132
174
  });
133
175
 
134
- test("only standard is available", () => {
135
- expect(TEMPLATES).toEqual(["standard"]);
176
+ test("standard and advanced are available", () => {
177
+ expect(TEMPLATES).toEqual(["standard", "advanced"]);
178
+ });
179
+ });
180
+
181
+ describe("defaultsBranchOptions template hints", () => {
182
+ test("reuse hint names the selected template", () => {
183
+ const reuse = defaultsBranchOptions(true, "advanced").find((o) => o.value === "reuse");
184
+ expect(reuse?.hint).toContain("advanced");
185
+ });
186
+ });
187
+
188
+ describe("parseArgs AI flags", () => {
189
+ test("accepts --ai / --no-ai / --ai skip", () => {
190
+ expect(parseArgs(["x"]).ai).toBe("prompt");
191
+ expect(parseArgs(["x", "--ai"]).ai).toBe("force");
192
+ expect(parseArgs(["x", "--no-ai"]).ai).toBe("skip");
193
+ expect(parseArgs(["x", "--ai", "skip"]).ai).toBe("skip");
194
+ expect(parseArgs(["x", "--ai=skip"]).ai).toBe("skip");
195
+ });
196
+ });
197
+
198
+ describe("wizard ← Back", () => {
199
+ test("withBackOption appends sentinel when allowed", () => {
200
+ const base = [{ value: "memory", label: "memory" }];
201
+ expect(withBackOption(base, false)).toEqual(base);
202
+ const withBack = withBackOption(base, true);
203
+ expect(withBack.at(-1)).toEqual({ value: WIZARD_BACK, label: "← Back" });
204
+ expect(WIZARD_BACK).toBe("__back__");
205
+ });
206
+ });
207
+
208
+ describe("aiSetupProviderFor", () => {
209
+ test("prefers ollama when docker is ollama even if local is mock", () => {
210
+ expect(
211
+ aiSetupProviderFor("mock", {
212
+ local: "mock",
213
+ docker: "ollama",
214
+ test: "mock",
215
+ prod: "ollama",
216
+ }),
217
+ ).toBe("ollama");
218
+ });
219
+
220
+ test("keeps local ollama", () => {
221
+ expect(
222
+ aiSetupProviderFor("ollama", {
223
+ local: "ollama",
224
+ docker: "anthropic",
225
+ test: "mock",
226
+ prod: "anthropic",
227
+ }),
228
+ ).toBe("ollama");
229
+ });
230
+ });
231
+
232
+ describe("interactive branches", () => {
233
+ test("recommended → no createDefaults, no AI setup", async () => {
234
+ const dir = mkdtempSync(join(tmpdir(), "oke-rec-"));
235
+ rmSync(dir, { recursive: true, force: true });
236
+ try {
237
+ const code = await run([dir], {
238
+ stdinIsTTY: true,
239
+ runPostScaffold: false,
240
+ ask: async () => recommendedAnswers({ name: dir }),
241
+ });
242
+ expect(code).toBe(0);
243
+ const config = readFileSync(join(dir, "oke.config.ts"), "utf8");
244
+ expect(config).toContain('local: "sqlite"');
245
+ expect(readFileSync(join(dir, ".oke", "mode"), "utf8").trim()).toBe("local");
246
+ expect(existsSync(join(dir, "src", "flows", "notes", "index.ts"))).toBe(true);
247
+ } finally {
248
+ rmSync(dir, { recursive: true, force: true });
249
+ }
250
+ });
251
+
252
+ test("recommended advanced → docker mode + advanced Notes flows", async () => {
253
+ const dir = mkdtempSync(join(tmpdir(), "oke-rec-adv-"));
254
+ rmSync(dir, { recursive: true, force: true });
255
+ try {
256
+ const code = await run([dir], {
257
+ stdinIsTTY: true,
258
+ runPostScaffold: false,
259
+ ask: async () => recommendedAnswers({ name: dir, choice: "advanced" }),
260
+ });
261
+ expect(code).toBe(0);
262
+ expect(readFileSync(join(dir, ".oke", "mode"), "utf8").trim()).toBe("docker");
263
+ const notes = readFileSync(join(dir, "src", "flows", "notes", "index.ts"), "utf8");
264
+ expect(notes).toContain('name: "notes.digest"');
265
+ expect(notes).toContain('name: "notes.attach"');
266
+ } finally {
267
+ rmSync(dir, { recursive: true, force: true });
268
+ }
269
+ });
270
+
271
+ test("reuse previous settings round-trip", async () => {
272
+ const home = mkdtempSync(join(tmpdir(), "oke-reuse-"));
273
+ const path = createDefaultsPath(home);
274
+ const base = recommendedDefaults("local-only");
275
+ const saved = {
276
+ ...base,
277
+ drivers: {
278
+ ...base.drivers,
279
+ store: {
280
+ ...base.drivers.store,
281
+ sql: {
282
+ local: "libsql",
283
+ docker: "postgres",
284
+ test: "memory",
285
+ prod: "postgres",
286
+ },
287
+ },
288
+ },
289
+ };
290
+ writeCreateDefaults(saved, path);
291
+
292
+ const dir = mkdtempSync(join(tmpdir(), "oke-reuse-app-"));
293
+ rmSync(dir, { recursive: true, force: true });
294
+
295
+ try {
296
+ const code = await run([dir], {
297
+ stdinIsTTY: true,
298
+ runPostScaffold: false,
299
+ ask: async () =>
300
+ recommendedAnswers({
301
+ name: dir,
302
+ createDefaults: saved,
303
+ aiApply: null,
304
+ }),
305
+ });
306
+ expect(code).toBe(0);
307
+ const config = readFileSync(join(dir, "oke.config.ts"), "utf8");
308
+ expect(config).toContain('local: "libsql"');
309
+ const templateConfig = readFileSync(
310
+ join(resolveTemplateDir("standard"), "oke.config.ts"),
311
+ "utf8",
312
+ );
313
+ expect(() => applyCreateAnswers(templateConfig, saved)).not.toThrow();
314
+ } finally {
315
+ rmSync(home, { recursive: true, force: true });
316
+ rmSync(dir, { recursive: true, force: true });
317
+ }
318
+ });
319
+
320
+ test("customize answers applied + persistence write", async () => {
321
+ const home = mkdtempSync(join(tmpdir(), "oke-custom-"));
322
+ const path = createDefaultsPath(home);
323
+ const customized = toCreateDefaults({
324
+ template: "standard",
325
+ profile: "local-only",
326
+ drivers: {
327
+ store: {
328
+ sql: pinsLocalOnly("pglite", "postgres", "memory"),
329
+ kv: pinsLocalOnly("memory", "redis", "memory"),
330
+ files: pinsLocalOnly("fs", "s3", "memory"),
331
+ index: null,
332
+ },
333
+ signal: pinsLocalOnly("memory", "redis", "memory"),
334
+ clock: pinsLocalOnly("memory", "file", "frozen"),
335
+ vault: pinsLocalOnly("env", "openbao", "memory"),
336
+ channel: { email: pinsLocalOnly("console", "smtp", "console") },
337
+ ai: null,
338
+ },
339
+ ai: { enabled: false, provider: null, driver: null },
340
+ });
341
+
342
+ const dir = mkdtempSync(join(tmpdir(), "oke-custom-app-"));
343
+ rmSync(dir, { recursive: true, force: true });
344
+
345
+ try {
346
+ const code = await run([dir], {
347
+ stdinIsTTY: true,
348
+ runPostScaffold: false,
349
+ ask: async (partial) => {
350
+ writeCreateDefaults(customized, path);
351
+ return recommendedAnswers({
352
+ name: dir,
353
+ createDefaults: customized,
354
+ agentsMd: partial.agentsMd ?? true,
355
+ });
356
+ },
357
+ });
358
+ expect(code).toBe(0);
359
+ expect(existsSync(path)).toBe(true);
360
+ expect(readCreateDefaults(path)?.drivers.store.sql.local).toBe("pglite");
361
+ expect(readFileSync(join(dir, "oke.config.ts"), "utf8")).toContain('local: "pglite"');
362
+ // local-only profile → seed oke dev mode so the prompt is not repeated.
363
+ expect(readFileSync(join(dir, ".oke", "mode"), "utf8").trim()).toBe("local");
364
+ } finally {
365
+ rmSync(home, { recursive: true, force: true });
366
+ rmSync(dir, { recursive: true, force: true });
367
+ }
368
+ });
369
+
370
+ test("docker-ready profile seeds .oke/mode as docker", async () => {
371
+ const customized = toCreateDefaults({
372
+ template: "advanced",
373
+ profile: "docker-ready",
374
+ drivers: {
375
+ store: {
376
+ sql: pinsDockerReady("sqlite", "postgres", "memory"),
377
+ kv: pinsLocalOnly("memory", "redis", "memory"),
378
+ files: pinsLocalOnly("fs", "s3", "memory"),
379
+ index: null,
380
+ },
381
+ signal: pinsLocalOnly("memory", "redis", "memory"),
382
+ clock: pinsLocalOnly("memory", "file", "frozen"),
383
+ vault: pinsLocalOnly("env", "openbao", "memory"),
384
+ channel: { email: pinsLocalOnly("console", "smtp", "console") },
385
+ ai: null,
386
+ },
387
+ ai: { enabled: false, provider: null, driver: null },
388
+ });
389
+ const dir = mkdtempSync(join(tmpdir(), "oke-docker-mode-"));
390
+ rmSync(dir, { recursive: true, force: true });
391
+ try {
392
+ const code = await run([dir], {
393
+ stdinIsTTY: true,
394
+ runPostScaffold: false,
395
+ ask: async () =>
396
+ recommendedAnswers({
397
+ name: dir,
398
+ createDefaults: customized,
399
+ aiApply: null,
400
+ }),
401
+ });
402
+ expect(code).toBe(0);
403
+ expect(readFileSync(join(dir, ".oke", "mode"), "utf8").trim()).toBe("docker");
404
+ } finally {
405
+ rmSync(dir, { recursive: true, force: true });
406
+ }
407
+ });
408
+
409
+ test("non-TTY --yes stays zero-prompt recommended", async () => {
410
+ const dir = mkdtempSync(join(tmpdir(), "oke-yes-"));
411
+ rmSync(dir, { recursive: true, force: true });
412
+ try {
413
+ let askCalled = false;
414
+ const code = await run([dir, "--yes", "--no-install", "--no-ai"], {
415
+ stdinIsTTY: false,
416
+ runPostScaffold: false,
417
+ ask: async () => {
418
+ askCalled = true;
419
+ return recommendedAnswers({ name: dir });
420
+ },
421
+ });
422
+ expect(code).toBe(0);
423
+ expect(askCalled).toBe(false);
424
+ const config = readFileSync(join(dir, "oke.config.ts"), "utf8");
425
+ expect(config).toContain('local: "sqlite"');
426
+ expect(config).not.toMatch(/\bai:\s*\{/);
427
+ } finally {
428
+ rmSync(dir, { recursive: true, force: true });
429
+ }
136
430
  });
137
431
  });
138
432
 
@@ -234,9 +528,12 @@ describe("scaffold structure", () => {
234
528
  name: `app-${id}`,
235
529
  source: { kind: "template", id },
236
530
  });
237
- const extras = ["AGENTS.md"];
531
+ const extras = ["AGENTS.md", ".oke/mode"];
238
532
  if (expected.includes(".env.example")) extras.push(".env.local");
239
533
  expect([...result.files].sort()).toEqual([...expected, ...extras].sort());
534
+ expect(readFileSync(join(result.targetDir, ".oke", "mode"), "utf8").trim()).toBe(
535
+ TEMPLATE_DEFAULT_MODE[id],
536
+ );
240
537
  expect(result.files).toContain(".gitignore");
241
538
  expect(result.files).toContain("README.md");
242
539
  expect(result.sqlDriver).toBe("sqlite");
@@ -245,21 +542,22 @@ describe("scaffold structure", () => {
245
542
  );
246
543
  const readme = readFileSync(join(result.targetDir, "README.md"), "utf8");
247
544
  expect(readme).toMatch(/oke dev/);
248
- expect(readme).toMatch(new RegExp(`^# ${id}`, "m"));
545
+ expect(readme).toMatch(new RegExp(`Notes \\(${id}\\)`, "i"));
546
+ expect(readme).toMatch(/notes\.(create|attach|digest)|main\.health/);
249
547
  expect(readFileSync(join(result.targetDir, ".gitignore"), "utf8")).toMatch(/node_modules/);
250
548
  const pkg = JSON.parse(readFileSync(join(result.targetDir, "package.json"), "utf8")) as {
251
549
  name: string;
252
550
  dependencies: { okengine: string };
253
551
  };
254
552
  expect(pkg.name).toBe(`app-${id}`);
255
- expect(pkg.dependencies.okengine).not.toBe("file:../..");
553
+ expect(pkg.dependencies.okengine).not.toMatch(/^file:\.\./);
256
554
  } finally {
257
555
  rmSync(dir, { recursive: true, force: true });
258
556
  }
259
557
  }
260
558
  });
261
559
 
262
- test("standard has full four-applications layout and no business domain", () => {
560
+ test("standard has Notes layout (gates/vault/channels + notes flows)", () => {
263
561
  const dir = mkdtempSync(join(tmpdir(), "create-oke-standard-assert-"));
264
562
  try {
265
563
  const result = scaffold({
@@ -275,12 +573,16 @@ describe("scaffold structure", () => {
275
573
  "src/locales/ar.ts",
276
574
  "src/flows/main/shapes.ts",
277
575
  "src/flows/main/signals.ts",
576
+ "src/flows/notes/index.ts",
278
577
  "src/core.ts",
279
578
  "src/schema.decl.ts",
280
579
  "src/app.ts",
281
580
  ]) {
282
581
  expect(result.files).toContain(path);
283
582
  }
583
+ const notes = readFileSync(join(result.targetDir, "src/flows/notes/index.ts"), "utf8");
584
+ expect(notes).toContain('name: "notes.create"');
585
+ expect(notes).not.toContain('name: "notes.digest"');
284
586
  const all = result.files
285
587
  .filter((f) => f.endsWith(".ts") || f.endsWith(".md"))
286
588
  .map((f) => readFileSync(join(result.targetDir, f), "utf8"))
@@ -335,12 +637,67 @@ describe("scaffold structure", () => {
335
637
  });
336
638
  });
337
639
 
640
+ describe("targetDirectoryBlockReason", () => {
641
+ test("null when missing or empty", () => {
642
+ const root = mkdtempSync(join(tmpdir(), "oke-target-ok-"));
643
+ const missing = join(root, "fresh");
644
+ const empty = join(root, "empty");
645
+ mkdirSync(empty);
646
+ try {
647
+ expect(targetDirectoryBlockReason(missing)).toBeNull();
648
+ expect(targetDirectoryBlockReason(empty)).toBeNull();
649
+ } finally {
650
+ rmSync(root, { recursive: true, force: true });
651
+ }
652
+ });
653
+
654
+ test("names the conflicting folder when not empty", () => {
655
+ const root = mkdtempSync(join(tmpdir(), "oke-target-busy-"));
656
+ const busy = join(root, "oke-1");
657
+ mkdirSync(busy);
658
+ writeFileSync(join(busy, "package.json"), "{}\n");
659
+ try {
660
+ const reason = targetDirectoryBlockReason(busy);
661
+ expect(reason).toMatch(/"oke-1" already exists and is not empty/);
662
+ expect(reason).toContain(busy);
663
+ } finally {
664
+ rmSync(root, { recursive: true, force: true });
665
+ }
666
+ });
667
+ });
668
+
338
669
  describe("non-TTY CLI", () => {
339
670
  test("no args + non-TTY → zero prompts, exit 1", async () => {
340
671
  const code = await run([], { stdinIsTTY: false, runPostScaffold: false });
341
672
  expect(code).toBe(1);
342
673
  });
343
674
 
675
+ test("existing non-empty dir fails before scaffold", async () => {
676
+ const root = mkdtempSync(join(tmpdir(), "create-oke-exists-"));
677
+ const target = join(root, "oke-1");
678
+ mkdirSync(target);
679
+ writeFileSync(join(target, "README.md"), "already here\n");
680
+ try {
681
+ const proc = Bun.spawn(
682
+ ["bun", "run", join(packageRoot(), "src/index.ts"), target, "--yes", "--no-install"],
683
+ {
684
+ cwd: root,
685
+ stdin: "pipe",
686
+ stdout: "pipe",
687
+ stderr: "pipe",
688
+ },
689
+ );
690
+ proc.stdin.end();
691
+ const code = await proc.exited;
692
+ const err = await new Response(proc.stderr).text();
693
+ expect(code).toBe(1);
694
+ expect(err).toMatch(/"oke-1" already exists and is not empty/);
695
+ expect(readFileSync(join(target, "README.md"), "utf8")).toBe("already here\n");
696
+ } finally {
697
+ rmSync(root, { recursive: true, force: true });
698
+ }
699
+ });
700
+
344
701
  test("spawned with piped stdin shows no clack prompts", async () => {
345
702
  const proc = Bun.spawn(["bun", "run", join(packageRoot(), "src/index.ts")], {
346
703
  cwd: packageRoot(),