create-oke 0.1.4 → 0.2.1

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 (117) hide show
  1. package/README.md +55 -0
  2. package/{templates → examples}/linkly/package.json +1 -1
  3. package/{templates → examples}/notes/package.json +1 -1
  4. package/{templates → examples}/provisions/package.json +1 -1
  5. package/{templates → examples}/skyport/package.json +1 -1
  6. package/package.json +7 -3
  7. package/src/cli.test.ts +246 -18
  8. package/src/cli.ts +358 -32
  9. package/src/index.ts +9 -1
  10. package/src/scaffold.ts +50 -32
  11. package/src/sync-templates.ts +28 -7
  12. package/src/templates.ts +85 -12
  13. package/src/transform.ts +3 -2
  14. package/templates/full/oke.config.ts +23 -0
  15. package/templates/full/package.json +15 -0
  16. package/templates/full/src/ai.ts +11 -0
  17. package/templates/full/src/app.ts +34 -0
  18. package/templates/full/src/channels.ts +9 -0
  19. package/templates/full/src/core.ts +4 -0
  20. package/templates/full/src/flows/main/index.ts +27 -0
  21. package/templates/full/src/flows/main/shapes.ts +3 -0
  22. package/templates/full/src/flows/main/signals.ts +8 -0
  23. package/templates/full/src/gates.ts +4 -0
  24. package/templates/full/src/locales/ar.ts +3 -0
  25. package/templates/full/src/locales/en.ts +3 -0
  26. package/templates/full/src/schema.ts +8 -0
  27. package/templates/full/src/vault.ts +9 -0
  28. package/templates/full/tests/full.test.ts +11 -0
  29. package/templates/hello/README.md +13 -0
  30. package/templates/hello/oke.config.ts +3 -0
  31. package/templates/hello/package.json +14 -0
  32. package/templates/hello/src/app.ts +10 -0
  33. package/templates/hello/src/flows/hello/index.ts +10 -0
  34. package/templates/hello/tests/hello.test.ts +10 -0
  35. package/templates/minimal/oke.config.ts +7 -0
  36. package/templates/minimal/package.json +15 -0
  37. package/templates/minimal/src/app.ts +13 -0
  38. package/templates/minimal/src/core.ts +4 -0
  39. package/templates/minimal/src/flows/main/index.ts +32 -0
  40. package/templates/minimal/src/schema.ts +8 -0
  41. package/templates/minimal/tests/minimal.test.ts +13 -0
  42. package/templates/standard/oke.config.ts +19 -0
  43. package/templates/standard/package.json +15 -0
  44. package/templates/standard/src/app.ts +18 -0
  45. package/templates/standard/src/channels.ts +4 -0
  46. package/templates/standard/src/core.ts +4 -0
  47. package/templates/standard/src/flows/main/index.ts +14 -0
  48. package/templates/standard/src/flows/main/shapes.ts +2 -0
  49. package/templates/standard/src/flows/main/signals.ts +4 -0
  50. package/templates/standard/src/gates.ts +4 -0
  51. package/templates/standard/src/locales/ar.ts +2 -0
  52. package/templates/standard/src/locales/en.ts +2 -0
  53. package/templates/standard/src/schema.ts +9 -0
  54. package/templates/standard/src/vault.ts +4 -0
  55. package/templates/standard/tests/standard.test.ts +10 -0
  56. /package/{templates → examples}/linkly/oke.config.ts +0 -0
  57. /package/{templates → examples}/linkly/src/app.ts +0 -0
  58. /package/{templates → examples}/linkly/src/core.ts +0 -0
  59. /package/{templates → examples}/linkly/src/flows/analytics/index.ts +0 -0
  60. /package/{templates → examples}/linkly/src/flows/links/index.ts +0 -0
  61. /package/{templates → examples}/linkly/src/flows/links/shapes.ts +0 -0
  62. /package/{templates → examples}/linkly/src/flows/links/signals.ts +0 -0
  63. /package/{templates → examples}/linkly/src/gates.ts +0 -0
  64. /package/{templates → examples}/linkly/src/schema.ts +0 -0
  65. /package/{templates → examples}/linkly/tests/linkly.test.ts +0 -0
  66. /package/{templates → examples}/notes/oke.config.ts +0 -0
  67. /package/{templates → examples}/notes/src/app.ts +0 -0
  68. /package/{templates → examples}/notes/src/core.ts +0 -0
  69. /package/{templates → examples}/notes/src/flows/notes/index.ts +0 -0
  70. /package/{templates → examples}/notes/src/schema.ts +0 -0
  71. /package/{templates → examples}/notes/tests/notes.test.ts +0 -0
  72. /package/{templates → examples}/provisions/oke.config.ts +0 -0
  73. /package/{templates → examples}/provisions/src/app.ts +0 -0
  74. /package/{templates → examples}/provisions/src/channels.ts +0 -0
  75. /package/{templates → examples}/provisions/src/core.ts +0 -0
  76. /package/{templates → examples}/provisions/src/flows/notifications/index.ts +0 -0
  77. /package/{templates → examples}/provisions/src/flows/orders/index.ts +0 -0
  78. /package/{templates → examples}/provisions/src/flows/orders/shapes.ts +0 -0
  79. /package/{templates → examples}/provisions/src/flows/orders/signals.ts +0 -0
  80. /package/{templates → examples}/provisions/src/flows/payments/index.ts +0 -0
  81. /package/{templates → examples}/provisions/src/flows/payments/shapes.ts +0 -0
  82. /package/{templates → examples}/provisions/src/flows/payments/stripe.ts +0 -0
  83. /package/{templates → examples}/provisions/src/gates.ts +0 -0
  84. /package/{templates → examples}/provisions/src/locales/ar.ts +0 -0
  85. /package/{templates → examples}/provisions/src/locales/en.ts +0 -0
  86. /package/{templates → examples}/provisions/src/plugins/audit-schema.ts +0 -0
  87. /package/{templates → examples}/provisions/src/plugins/audit.ts +0 -0
  88. /package/{templates → examples}/provisions/src/schema.ts +0 -0
  89. /package/{templates → examples}/provisions/src/vault.ts +0 -0
  90. /package/{templates → examples}/provisions/tests/orders.test.ts +0 -0
  91. /package/{templates → examples}/skyport/evals/triage.jsonl +0 -0
  92. /package/{templates → examples}/skyport/oke.config.ts +0 -0
  93. /package/{templates → examples}/skyport/oke.images.lock +0 -0
  94. /package/{templates → examples}/skyport/src/ai.ts +0 -0
  95. /package/{templates → examples}/skyport/src/app.ts +0 -0
  96. /package/{templates → examples}/skyport/src/channels.ts +0 -0
  97. /package/{templates → examples}/skyport/src/core.ts +0 -0
  98. /package/{templates → examples}/skyport/src/flows/bookings/index.ts +0 -0
  99. /package/{templates → examples}/skyport/src/flows/bookings/shapes.ts +0 -0
  100. /package/{templates → examples}/skyport/src/flows/bookings/signals.ts +0 -0
  101. /package/{templates → examples}/skyport/src/flows/notifications/index.ts +0 -0
  102. /package/{templates → examples}/skyport/src/flows/payments/index.ts +0 -0
  103. /package/{templates → examples}/skyport/src/flows/payments/shapes.ts +0 -0
  104. /package/{templates → examples}/skyport/src/flows/support/index.ts +0 -0
  105. /package/{templates → examples}/skyport/src/flows/users/elements.ts +0 -0
  106. /package/{templates → examples}/skyport/src/flows/users/index.ts +0 -0
  107. /package/{templates → examples}/skyport/src/flows/users/shapes.ts +0 -0
  108. /package/{templates → examples}/skyport/src/gates.ts +0 -0
  109. /package/{templates → examples}/skyport/src/journeys.ts +0 -0
  110. /package/{templates → examples}/skyport/src/locales/ar.ts +0 -0
  111. /package/{templates → examples}/skyport/src/locales/en.ts +0 -0
  112. /package/{templates → examples}/skyport/src/plugins/audit-schema.ts +0 -0
  113. /package/{templates → examples}/skyport/src/plugins/audit.ts +0 -0
  114. /package/{templates → examples}/skyport/src/plugins/panel.tsx +0 -0
  115. /package/{templates → examples}/skyport/src/schema.ts +0 -0
  116. /package/{templates → examples}/skyport/src/vault.ts +0 -0
  117. /package/{templates → examples}/skyport/tests/support.test.ts +0 -0
package/src/cli.ts CHANGED
@@ -1,17 +1,34 @@
1
1
  /**
2
- * `create-oke` CLI — one command, sensible default, done.
2
+ * `create-oke` CLI — clean templates by default; teaching examples opt-in.
3
3
  *
4
4
  * ```bash
5
- * bunx create-oke@latest <name> [--template notes|linkly|provisions|skyport]
5
+ * bunx create-oke@latest <name> [--template hello|minimal|standard|full]
6
+ * bunx create-oke@latest <name> --from-example notes|linkly|provisions|skyport
7
+ * bunx create-oke@latest # interactive when stdin is a TTY
6
8
  * ```
7
9
  */
8
10
 
11
+ import {
12
+ cancel,
13
+ intro,
14
+ isCancel,
15
+ outro,
16
+ select,
17
+ spinner,
18
+ text,
19
+ } from "@clack/prompts";
9
20
  import { basename, relative, resolve } from "node:path";
10
- import { scaffold } from "./scaffold.ts";
21
+ import { existsSync, rmSync } from "node:fs";
22
+ import { scaffold, type ScaffoldResult, type ScaffoldSource } from "./scaffold.ts";
11
23
  import {
12
24
  DEFAULT_TEMPLATE,
25
+ EXAMPLE_NEW_IDEAS,
26
+ EXAMPLES,
27
+ TEMPLATE_PURPOSES,
13
28
  TEMPLATES,
29
+ isExampleId,
14
30
  isTemplateId,
31
+ type ExampleId,
15
32
  type TemplateId,
16
33
  } from "./templates.ts";
17
34
 
@@ -19,10 +36,40 @@ import {
19
36
  export type CliArgs = {
20
37
  readonly name: string | undefined;
21
38
  readonly template: TemplateId;
39
+ readonly fromExample: ExampleId | undefined;
40
+ /** True when `--template` / `-t` was present on the argv. */
41
+ readonly templateExplicit: boolean;
22
42
  readonly help: boolean;
23
43
  readonly targetDir: string | undefined;
24
44
  };
25
45
 
46
+ /**
47
+ * Sentinel choice for "Start from a worked example" in interactive answers.
48
+ * Distinct from clack's internal select value (`__example__`).
49
+ */
50
+ export const FROM_EXAMPLE_CHOICE = "from-example" as const;
51
+
52
+ /** Answers collected by the interactive ask step (no clack types). */
53
+ export type InteractiveAnswers =
54
+ | {
55
+ readonly name: string;
56
+ readonly choice: TemplateId;
57
+ }
58
+ | {
59
+ readonly name: string;
60
+ readonly choice: typeof FROM_EXAMPLE_CHOICE;
61
+ readonly example: ExampleId;
62
+ };
63
+
64
+ /**
65
+ * Canonical scaffold invocation — shared by interactive and flag-driven paths.
66
+ */
67
+ export type ScaffoldCallArgs = {
68
+ readonly name: string;
69
+ readonly targetDir: string;
70
+ readonly source: ScaffoldSource;
71
+ };
72
+
26
73
  /**
27
74
  * Parse argv after the binary name.
28
75
  *
@@ -31,6 +78,8 @@ export type CliArgs = {
31
78
  export function parseArgs(argv: readonly string[]): CliArgs {
32
79
  let name: string | undefined;
33
80
  let template: TemplateId = DEFAULT_TEMPLATE;
81
+ let fromExample: ExampleId | undefined;
82
+ let templateExplicit = false;
34
83
  let help = false;
35
84
  let targetDir: string | undefined;
36
85
 
@@ -48,6 +97,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
48
97
  );
49
98
  }
50
99
  template = next;
100
+ templateExplicit = true;
51
101
  continue;
52
102
  }
53
103
  if (a.startsWith("--template=")) {
@@ -58,6 +108,27 @@ export function parseArgs(argv: readonly string[]): CliArgs {
58
108
  );
59
109
  }
60
110
  template = value;
111
+ templateExplicit = true;
112
+ continue;
113
+ }
114
+ if (a === "--from-example") {
115
+ const next = argv[++i];
116
+ if (!next || !isExampleId(next)) {
117
+ throw new Error(
118
+ `create-oke: --from-example must be one of ${EXAMPLES.join("|")}`,
119
+ );
120
+ }
121
+ fromExample = next;
122
+ continue;
123
+ }
124
+ if (a.startsWith("--from-example=")) {
125
+ const value = a.slice("--from-example=".length);
126
+ if (!isExampleId(value)) {
127
+ throw new Error(
128
+ `create-oke: --from-example must be one of ${EXAMPLES.join("|")}`,
129
+ );
130
+ }
131
+ fromExample = value;
61
132
  continue;
62
133
  }
63
134
  if (a.startsWith("-")) {
@@ -70,11 +141,17 @@ export function parseArgs(argv: readonly string[]): CliArgs {
70
141
  throw new Error(`create-oke: unexpected argument ${a}`);
71
142
  }
72
143
 
144
+ if (templateExplicit && fromExample !== undefined) {
145
+ throw new Error(
146
+ "create-oke: use either --template or --from-example, not both",
147
+ );
148
+ }
149
+
73
150
  if (name !== undefined) {
74
151
  targetDir = resolve(name);
75
152
  }
76
153
 
77
- return { name, template, help, targetDir };
154
+ return { name, template, fromExample, templateExplicit, help, targetDir };
78
155
  }
79
156
 
80
157
  /**
@@ -88,32 +165,218 @@ export function formatCdPath(targetDir: string): string {
88
165
  return rel;
89
166
  }
90
167
 
168
+ /**
169
+ * Post-scaffold next-steps block — shared by interactive and flag-driven paths.
170
+ *
171
+ * @param result - Successful scaffold result
172
+ */
173
+ export function nextStepsText(result: ScaffoldResult): string {
174
+ return `
175
+ Scaffolded ${result.label} → ${result.targetDir}
176
+
177
+ Next steps:
178
+
179
+ cd ${formatCdPath(result.targetDir)}
180
+ bun install
181
+ oke dev # app :6530 · Console :6533 · MCP :6535
182
+ `;
183
+ }
184
+
91
185
  /**
92
186
  * Help text — next steps match four-applications.md (`bun install` · `oke dev`).
93
187
  */
94
188
  export function helpText(): string {
189
+ const templateLines = TEMPLATES.map(
190
+ (id) =>
191
+ ` ${id.padEnd(12)}${TEMPLATE_PURPOSES[id]}${id === DEFAULT_TEMPLATE ? " (default)" : ""}`,
192
+ ).join("\n");
193
+ const exampleLines = EXAMPLES.map(
194
+ (id) => ` ${id.padEnd(12)}${EXAMPLE_NEW_IDEAS[id]}`,
195
+ ).join("\n");
196
+
95
197
  return `create-oke — scaffold an okengine app
96
198
 
97
199
  Usage:
98
- bunx create-oke@latest <name> [--template notes|linkly|provisions|skyport]
200
+ bunx create-oke@latest <name> [--template hello|minimal|standard|full]
201
+ bunx create-oke@latest <name> --from-example notes|linkly|provisions|skyport
202
+ bunx create-oke@latest # interactive (TTY only)
203
+
204
+ Templates (clean starters from templates/):
205
+ ${templateLines}
99
206
 
100
- Templates (from examples/):
101
- notes basicFlow · Store (default)
102
- linkly intermediate — + Signal · Clock · Gate
103
- provisions advanced — + Vault · Channel · plugins
104
- skyport complex — all eight elements · AI · tenancy
207
+ --from-example (copies a teaching example, including its business logic and
208
+ commentsmost new projects want --template instead):
209
+ ${exampleLines}
105
210
 
106
- No telemetry. Bun only. No interactive wizard.
211
+ No telemetry. Bun only. Interactive prompts only when stdin is a TTY and no
212
+ name / --template / --from-example is given.
107
213
  `;
108
214
  }
109
215
 
216
+ /**
217
+ * Whether the CLI should open the two-question interactive flow.
218
+ *
219
+ * Mirrors oke / gflows: bare invocation is interactive only in a real terminal;
220
+ * any explicit flag or positional name stays fully scriptable.
221
+ *
222
+ * @param args - Parsed args
223
+ * @param stdinIsTTY - `process.stdin.isTTY`
224
+ */
225
+ export function shouldPrompt(
226
+ args: CliArgs,
227
+ stdinIsTTY: boolean | undefined,
228
+ ): boolean {
229
+ if (!stdinIsTTY) return false;
230
+ if (args.help) return false;
231
+ if (args.name !== undefined) return false;
232
+ if (args.templateExplicit) return false;
233
+ if (args.fromExample !== undefined) return false;
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Build the {@link ScaffoldSource} for a flag-driven invocation.
239
+ *
240
+ * @param args - Parsed args
241
+ */
242
+ export function sourceFromArgs(args: CliArgs): ScaffoldSource {
243
+ if (args.fromExample !== undefined) {
244
+ return { kind: "example", id: args.fromExample };
245
+ }
246
+ return { kind: "template", id: args.template };
247
+ }
248
+
249
+ /**
250
+ * Map flag-driven {@link CliArgs} to scaffold call args.
251
+ *
252
+ * Requires a positional project name (and thus `targetDir`).
253
+ *
254
+ * @param args - Parsed CLI args with `name` set
255
+ */
256
+ export function scaffoldArgsFromCli(args: CliArgs): ScaffoldCallArgs {
257
+ if (args.name === undefined || args.targetDir === undefined) {
258
+ throw new Error("create-oke: missing <name>");
259
+ }
260
+ return {
261
+ name: basename(resolve(args.name)),
262
+ targetDir: args.targetDir,
263
+ source: sourceFromArgs(args),
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Pure map from interactive answers → scaffold call args.
269
+ *
270
+ * Independent of clack — unit-tested against {@link scaffoldArgsFromCli}
271
+ * for every template and every `--from-example` choice.
272
+ *
273
+ * @param answers - Collected interactive answers
274
+ */
275
+ export function scaffoldArgsFromAnswers(
276
+ answers: InteractiveAnswers,
277
+ ): ScaffoldCallArgs {
278
+ const targetDir = resolve(answers.name.trim());
279
+ const name = basename(targetDir);
280
+ if (answers.choice === FROM_EXAMPLE_CHOICE) {
281
+ return {
282
+ name,
283
+ targetDir,
284
+ source: { kind: "example", id: answers.example },
285
+ };
286
+ }
287
+ return {
288
+ name,
289
+ targetDir,
290
+ source: { kind: "template", id: answers.choice },
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Ask step — clack prompts only. Returns answers or `null` on cancel.
296
+ *
297
+ * Injectable for tests; production uses {@link askInteractiveAnswers}.
298
+ *
299
+ * @param partial - Name already known (skipped in the prompt)
300
+ */
301
+ export type AskInteractiveFn = (
302
+ partial: { readonly name?: string },
303
+ ) => Promise<InteractiveAnswers | null>;
304
+
305
+ /**
306
+ * Collect interactive answers via `@clack/prompts`.
307
+ *
308
+ * @param partial - Optional pre-filled project name
309
+ * @returns Answers, or `null` if the user cancelled
310
+ */
311
+ export async function askInteractiveAnswers(
312
+ partial: { readonly name?: string } = {},
313
+ ): Promise<InteractiveAnswers | null> {
314
+ let name = partial.name;
315
+ if (name === undefined) {
316
+ const nameValue = await text({
317
+ message: "Project name",
318
+ placeholder: "my-app",
319
+ validate: (value) => {
320
+ if (!value?.trim()) return "Project name is required";
321
+ return undefined;
322
+ },
323
+ });
324
+ if (isCancel(nameValue)) return null;
325
+ name = String(nameValue).trim();
326
+ }
327
+
328
+ const templateValue = await select({
329
+ message: "Template",
330
+ options: [
331
+ ...TEMPLATES.map((id) => ({
332
+ value: id as string,
333
+ label: id,
334
+ hint: TEMPLATE_PURPOSES[id],
335
+ })),
336
+ {
337
+ value: "__example__",
338
+ label: "Start from a worked example",
339
+ hint: "Teaching apps with business logic — most projects want a template",
340
+ },
341
+ ],
342
+ initialValue: DEFAULT_TEMPLATE,
343
+ });
344
+ if (isCancel(templateValue)) return null;
345
+
346
+ if (templateValue === "__example__") {
347
+ const exampleValue = await select({
348
+ message: "Example",
349
+ options: EXAMPLES.map((id) => ({
350
+ value: id,
351
+ label: id,
352
+ hint: EXAMPLE_NEW_IDEAS[id],
353
+ })),
354
+ });
355
+ if (isCancel(exampleValue)) return null;
356
+ return {
357
+ name,
358
+ choice: FROM_EXAMPLE_CHOICE,
359
+ example: exampleValue as ExampleId,
360
+ };
361
+ }
362
+
363
+ return { name, choice: templateValue as TemplateId };
364
+ }
365
+
110
366
  /**
111
367
  * Run the CLI.
112
368
  *
113
369
  * @param argv - Args after the binary
370
+ * @param options - Test seams (stdin TTY, ask injection)
114
371
  * @returns Exit code
115
372
  */
116
- export async function run(argv: readonly string[]): Promise<number> {
373
+ export async function run(
374
+ argv: readonly string[],
375
+ options: {
376
+ readonly stdinIsTTY?: boolean | undefined;
377
+ readonly ask?: AskInteractiveFn;
378
+ } = {},
379
+ ): Promise<number> {
117
380
  let args: CliArgs;
118
381
  try {
119
382
  args = parseArgs(argv);
@@ -122,35 +385,98 @@ export async function run(argv: readonly string[]): Promise<number> {
122
385
  return 1;
123
386
  }
124
387
 
125
- if (args.help || args.name === undefined) {
388
+ if (args.help) {
126
389
  console.log(helpText());
127
- if (args.name === undefined && !args.help) {
128
- console.error("create-oke: missing <name>");
129
- return 1;
130
- }
131
- return args.help ? 0 : 1;
390
+ return 0;
132
391
  }
133
392
 
134
- const projectName = basename(resolve(args.name));
135
- try {
136
- const result = scaffold({
137
- targetDir: args.targetDir!,
138
- name: projectName,
139
- template: args.template,
140
- });
393
+ const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY;
394
+ if (shouldPrompt(args, stdinIsTTY)) {
395
+ return runInteractive(args, options.ask ?? askInteractiveAnswers);
396
+ }
141
397
 
142
- console.log(`
143
- Scaffolded ${result.template} → ${result.targetDir}
398
+ if (args.name === undefined) {
399
+ console.log(helpText());
400
+ console.error("create-oke: missing <name>");
401
+ return 1;
402
+ }
144
403
 
145
- Next steps:
404
+ return runScaffold({
405
+ ...scaffoldArgsFromCli(args),
406
+ interactive: false,
407
+ });
408
+ }
146
409
 
147
- cd ${formatCdPath(result.targetDir)}
148
- bun install
149
- oke dev # app :6530 · Console :6533 · MCP :6535
150
- `);
410
+ /**
411
+ * Interactive TTY flow — ask → map answers → scaffold.
412
+ *
413
+ * @param args - Parsed args (name may already be set if ever called that way)
414
+ * @param ask - Injectable ask step
415
+ */
416
+ async function runInteractive(
417
+ args: CliArgs,
418
+ ask: AskInteractiveFn,
419
+ ): Promise<number> {
420
+ intro("create-oke");
421
+
422
+ const answers = await ask({ name: args.name });
423
+ if (answers === null) {
424
+ cancel("Cancelled.");
425
+ return 1;
426
+ }
427
+
428
+ return runScaffold({
429
+ ...scaffoldArgsFromAnswers(answers),
430
+ interactive: true,
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Scaffold with optional spinner / outro, cleaning up on failure or cancel.
436
+ *
437
+ * @param options - Scaffold inputs + interactive flag
438
+ */
439
+ async function runScaffold(
440
+ options: ScaffoldCallArgs & { readonly interactive: boolean },
441
+ ): Promise<number> {
442
+ const { name, targetDir, source, interactive } = options;
443
+ const existed = existsSync(targetDir);
444
+ let spun: ReturnType<typeof spinner> | undefined;
445
+
446
+ const cleanup = (): void => {
447
+ if (!existed && existsSync(targetDir)) {
448
+ rmSync(targetDir, { recursive: true, force: true });
449
+ }
450
+ };
451
+
452
+ const onSigInt = (): void => {
453
+ spun?.stop("Cancelled.");
454
+ cleanup();
455
+ if (interactive) cancel("Cancelled.");
456
+ process.exit(1);
457
+ };
458
+ process.once("SIGINT", onSigInt);
459
+
460
+ try {
461
+ if (interactive) {
462
+ spun = spinner();
463
+ spun.start("Scaffolding…");
464
+ }
465
+ const result = scaffold({ targetDir, name, source });
466
+ if (spun) spun.stop("Scaffolded.");
467
+ const message = nextStepsText(result);
468
+ if (interactive) {
469
+ outro(message.trim());
470
+ } else {
471
+ console.log(message);
472
+ }
151
473
  return 0;
152
474
  } catch (e) {
475
+ spun?.stop("Failed.");
476
+ cleanup();
153
477
  console.error(e instanceof Error ? e.message : e);
154
478
  return 1;
479
+ } finally {
480
+ process.off("SIGINT", onSigInt);
155
481
  }
156
482
  }
package/src/index.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * `create-oke` binary entry.
3
+ * `create-oke` binary entry — scaffold an okengine app.
4
+ *
5
+ * ```bash
6
+ * bunx create-oke@latest my-app
7
+ * bunx create-oke@latest my-app --template hello
8
+ * bunx create-oke@latest my-app --from-example notes
9
+ * ```
10
+ *
11
+ * @module
4
12
  */
5
13
 
6
14
  import { run } from "./cli.ts";
package/src/scaffold.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Copy an `examples/<template>` tree and apply the package.json transform.
2
+ * Copy a template or example tree and apply the package.json transform.
3
3
  */
4
4
 
5
5
  import {
@@ -8,13 +8,16 @@ import {
8
8
  mkdirSync,
9
9
  readdirSync,
10
10
  readFileSync,
11
+ rmSync,
11
12
  statSync,
12
13
  writeFileSync,
13
14
  } from "node:fs";
14
15
  import { join, relative, resolve } from "node:path";
15
16
  import {
17
+ resolveExampleDir,
16
18
  resolveLocalOkengineRoot,
17
19
  resolveTemplateDir,
20
+ type ExampleId,
18
21
  type TemplateId,
19
22
  } from "./templates.ts";
20
23
  import {
@@ -25,35 +28,46 @@ import {
25
28
  type ScaffoldPackageJson,
26
29
  } from "./transform.ts";
27
30
 
31
+ /** Where the scaffold copies from. */
32
+ export type ScaffoldSource =
33
+ | { readonly kind: "template"; readonly id: TemplateId }
34
+ | { readonly kind: "example"; readonly id: ExampleId };
35
+
28
36
  /** Options for {@link scaffold}. */
29
37
  export type ScaffoldOptions = {
30
38
  /** Destination directory (created). Absolute or cwd-relative. */
31
39
  readonly targetDir: string;
32
40
  /** npm package / folder name. */
33
41
  readonly name: string;
34
- /** Template id (default: notes). */
35
- readonly template: TemplateId;
42
+ /** Template or teaching-example source. */
43
+ readonly source: ScaffoldSource;
36
44
  };
37
45
 
38
46
  /** Result of a successful scaffold. */
39
47
  export type ScaffoldResult = {
40
48
  readonly targetDir: string;
41
49
  readonly name: string;
42
- readonly template: TemplateId;
50
+ readonly source: ScaffoldSource;
51
+ /** Display label (`standard`, `notes`, …). */
52
+ readonly label: string;
43
53
  readonly okengineDependency: string;
44
54
  /** Relative paths written (POSIX), sorted. */
45
55
  readonly files: readonly string[];
46
56
  };
47
57
 
48
58
  /**
49
- * Scaffold a new okengine project from `examples/<template>`.
59
+ * Scaffold a new okengine project from a clean template or teaching example.
50
60
  *
51
- * @param options - Name, template, destination
61
+ * @param options - Name, source, destination
52
62
  */
53
63
  export function scaffold(options: ScaffoldOptions): ScaffoldResult {
54
64
  const name = sanitizeProjectName(options.name);
55
65
  const targetDir = resolve(options.targetDir);
56
- const templateDir = resolveTemplateDir(options.template);
66
+ const sourceDir =
67
+ options.source.kind === "template"
68
+ ? resolveTemplateDir(options.source.id)
69
+ : resolveExampleDir(options.source.id);
70
+ const label = options.source.id;
57
71
 
58
72
  if (existsSync(targetDir)) {
59
73
  const entries = readdirSync(targetDir);
@@ -66,33 +80,37 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
66
80
  mkdirSync(targetDir, { recursive: true });
67
81
  }
68
82
 
69
- const okengineDependency = resolveOkengineDependency(
70
- resolveLocalOkengineRoot(),
71
- );
72
- const written: string[] = [];
83
+ try {
84
+ const okengineDependency = resolveOkengineDependency(
85
+ resolveLocalOkengineRoot(),
86
+ );
87
+ const written: string[] = [];
73
88
 
74
- copyTree(templateDir, targetDir, "", written);
89
+ copyTree(sourceDir, targetDir, "", written);
75
90
 
76
- const pkgPath = join(targetDir, "package.json");
77
- if (!existsSync(pkgPath)) {
78
- throw new Error(
79
- `create-oke: template "${options.template}" has no package.json`,
80
- );
81
- }
82
- const sourcePkg = JSON.parse(
83
- readFileSync(pkgPath, "utf8"),
84
- ) as ScaffoldPackageJson;
85
- const nextPkg = transformPackageJson(sourcePkg, name, okengineDependency);
86
- writeFileSync(pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`, "utf8");
91
+ const pkgPath = join(targetDir, "package.json");
92
+ if (!existsSync(pkgPath)) {
93
+ throw new Error(`create-oke: source "${label}" has no package.json`);
94
+ }
95
+ const sourcePkg = JSON.parse(
96
+ readFileSync(pkgPath, "utf8"),
97
+ ) as ScaffoldPackageJson;
98
+ const nextPkg = transformPackageJson(sourcePkg, name, okengineDependency);
99
+ writeFileSync(pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`, "utf8");
87
100
 
88
- written.sort();
89
- return {
90
- targetDir,
91
- name,
92
- template: options.template,
93
- okengineDependency,
94
- files: written,
95
- };
101
+ written.sort();
102
+ return {
103
+ targetDir,
104
+ name,
105
+ source: options.source,
106
+ label,
107
+ okengineDependency,
108
+ files: written,
109
+ };
110
+ } catch (e) {
111
+ rmSync(targetDir, { recursive: true, force: true });
112
+ throw e;
113
+ }
96
114
  }
97
115
 
98
116
  /**
@@ -129,7 +147,7 @@ function copyTree(
129
147
  }
130
148
 
131
149
  /**
132
- * List relative file paths in an example tree using the same skip rules as scaffold.
150
+ * List relative file paths in a source tree using the same skip rules as scaffold.
133
151
  *
134
152
  * @param templateDir - Absolute template directory
135
153
  */