@waniwani/kit 0.1.6-beta.0 → 0.1.7

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 (44) hide show
  1. package/README.md +39 -39
  2. package/dist/cli/codegen.js +1105 -0
  3. package/dist/cli/codegen.js.map +1 -0
  4. package/{cli/env.mjs → dist/cli/env.js} +5 -6
  5. package/dist/cli/env.js.map +1 -0
  6. package/{cli/framework.mjs → dist/cli/framework.js} +112 -115
  7. package/dist/cli/framework.js.map +1 -0
  8. package/dist/cli/index.js +378 -0
  9. package/dist/cli/index.js.map +1 -0
  10. package/{cli/init.mjs → dist/cli/init.js} +228 -276
  11. package/dist/cli/init.js.map +1 -0
  12. package/dist/cli/log.js +156 -0
  13. package/dist/cli/log.js.map +1 -0
  14. package/dist/cli/manifest.js +57 -0
  15. package/dist/cli/manifest.js.map +1 -0
  16. package/dist/cli/peers.js +159 -0
  17. package/dist/cli/peers.js.map +1 -0
  18. package/dist/cli/scan.js +100 -0
  19. package/dist/cli/scan.js.map +1 -0
  20. package/dist/cli/template.js +173 -0
  21. package/dist/cli/template.js.map +1 -0
  22. package/dist/cli/types.js +14 -0
  23. package/dist/cli/types.js.map +1 -0
  24. package/dist/cli/validate.js +328 -0
  25. package/dist/cli/validate.js.map +1 -0
  26. package/dist/cli/vercel.js +103 -0
  27. package/dist/cli/vercel.js.map +1 -0
  28. package/dist/server.d.ts +1 -1
  29. package/dist/server.d.ts.map +1 -1
  30. package/dist/server.js +0 -1
  31. package/dist/server.js.map +1 -1
  32. package/dist/web.d.ts +8 -7
  33. package/dist/web.d.ts.map +1 -1
  34. package/dist/web.js +7 -6
  35. package/dist/web.js.map +1 -1
  36. package/package.json +14 -14
  37. package/src/server.ts +7 -9
  38. package/src/web.tsx +12 -13
  39. package/cli/codegen.mjs +0 -1215
  40. package/cli/index.mjs +0 -409
  41. package/cli/log.mjs +0 -178
  42. package/cli/scan.mjs +0 -112
  43. package/cli/template.mjs +0 -190
  44. package/cli/validate.mjs +0 -327
@@ -26,108 +26,85 @@
26
26
  * alone. Only the app's own source files count as a collision, and those stop
27
27
  * the command until `--force` says otherwise.
28
28
  */
29
-
30
29
  import { spawn } from "node:child_process";
31
30
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
31
  import { basename, dirname, join, relative } from "node:path";
33
32
  import { createInterface } from "node:readline/promises";
34
- import { fileURLToPath } from "node:url";
35
- import { bold, dim, green, red, yellow } from "./log.mjs";
36
-
37
- const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
38
- const MANIFEST = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"));
39
-
33
+ import { bold, dim, green, red, yellow } from "./log.js";
34
+ import { PACKAGE_VERSION } from "./manifest.js";
35
+ import { installable } from "./peers.js";
40
36
  /** A directory name as an MCP server name: `My App` becomes `my-app`. */
41
37
  function slugify(input) {
42
- const slug = input
43
- .trim()
44
- .toLowerCase()
45
- .replace(/[^a-z0-9]+/g, "-")
46
- .replace(/^-+|-+$/g, "");
47
- return slug || "my-app";
38
+ const slug = input
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, "-")
42
+ .replace(/^-+|-+$/g, "");
43
+ return slug || "my-app";
48
44
  }
49
-
50
45
  /** `my-app` becomes `My app`, for the human-facing title. */
51
46
  function titleize(slug) {
52
- const words = slug.replace(/[-_]+/g, " ");
53
- return words.charAt(0).toUpperCase() + words.slice(1);
47
+ const words = slug.replace(/[-_]+/g, " ");
48
+ return words.charAt(0).toUpperCase() + words.slice(1);
54
49
  }
55
-
56
50
  /**
57
51
  * A title, made safe to drop into the generated TypeScript and Markdown.
58
52
  * Backticks, quotes, backslashes and `$` are the characters that would end a
59
53
  * string or a template literal early, and a product name needs none of them.
60
54
  */
61
55
  function cleanTitle(input) {
62
- return input
63
- .replace(/[`"'\\$]/g, "")
64
- .replace(/\s+/g, " ")
65
- .trim();
56
+ return input
57
+ .replace(/[`"'\\$]/g, "")
58
+ .replace(/\s+/g, " ")
59
+ .trim();
66
60
  }
67
-
68
- /**
69
- * A peer range is a floor, `>=19`, and a floor in an app's dependencies installs
70
- * the next major on the day it lands. Cap it. Anything already ranged, `^4`,
71
- * passes through as it is.
72
- */
73
- function installable(name, range) {
74
- if (!range) {
75
- throw new Error(`@waniwani/kit declares no peer range for ${name}: this package's manifest moved`);
76
- }
77
- const floor = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
78
- return floor ? `^${floor[1]}.${floor[2] ?? 0}.${floor[3] ?? 0}` : range;
79
- }
80
-
81
61
  /**
82
62
  * What a new app depends on.
83
63
  *
84
64
  * Every version is read off this package's own manifest: `@waniwani/kit` at the
85
- * version of the CLI doing the scaffolding, `@waniwani/sdk` at the version this
86
- * runtime is built against, and react, react-dom and zod at the peer ranges this
87
- * package declares. A scaffold that wrote its own numbers here would be the one
88
- * file in the folder that can be wrong the day it is created.
65
+ * version of the CLI doing the scaffolding, and the four peers at the floors
66
+ * this package declares, capped by `installable` so a floor does not install
67
+ * the next major on the day it lands. A scaffold that wrote its own numbers
68
+ * here would be the one file in the folder that can be wrong the day it is
69
+ * created.
70
+ *
71
+ * `@waniwani/sdk` is written out even though a required peer is auto-installed
72
+ * without it, because the app imports it directly — `flows/*.ts` calls
73
+ * `createFlow` — and a package you import belongs in your own manifest rather
74
+ * than arriving because something else asked for it.
89
75
  */
90
76
  function dependencies() {
91
- const peers = MANIFEST.peerDependencies ?? {};
92
- return {
93
- "@waniwani/kit": `^${MANIFEST.version}`,
94
- "@waniwani/sdk": MANIFEST.dependencies["@waniwani/sdk"],
95
- react: installable("react", peers.react),
96
- "react-dom": installable("react-dom", peers["react-dom"]),
97
- zod: installable("zod", peers.zod),
98
- };
77
+ return {
78
+ "@waniwani/kit": `^${PACKAGE_VERSION}`,
79
+ "@waniwani/sdk": installable("@waniwani/sdk"),
80
+ react: installable("react"),
81
+ "react-dom": installable("react-dom"),
82
+ zod: installable("zod"),
83
+ };
99
84
  }
100
-
101
85
  // ------------------------------------------------------------ scaffold content
102
-
103
86
  /**
104
87
  * The tool name and the widget name the scaffold uses. They appear in five
105
88
  * files, including inside prose the model reads, so they are named once.
106
89
  */
107
90
  const TOOL = "search-products";
108
91
  const WIDGET = "product-list";
109
-
110
92
  function packageJson(app) {
111
- return `${JSON.stringify(
112
- {
113
- name: app.name,
114
- private: true,
115
- type: "module",
116
- scripts: {
117
- check: "waniwani check",
118
- dev: "waniwani dev",
119
- build: "waniwani build",
120
- start: "waniwani start",
121
- },
122
- dependencies: dependencies(),
123
- },
124
- null,
125
- 2,
126
- )}\n`;
93
+ return `${JSON.stringify({
94
+ name: app.name,
95
+ private: true,
96
+ type: "module",
97
+ scripts: {
98
+ check: "waniwani check",
99
+ dev: "waniwani dev",
100
+ build: "waniwani build",
101
+ start: "waniwani start",
102
+ },
103
+ dependencies: dependencies(),
104
+ }, null, 2)}\n`;
127
105
  }
128
-
129
106
  function appConfig(app) {
130
- return `import { defineApp } from "@waniwani/kit";
107
+ return `import { defineApp } from "@waniwani/kit";
131
108
 
132
109
  export default defineApp({
133
110
  // The MCP server name. Hosts show \`title\` to humans and use this one as the id.
@@ -136,9 +113,8 @@ export default defineApp({
136
113
  });
137
114
  `;
138
115
  }
139
-
140
116
  function tool() {
141
- return `import { defineTool } from "@waniwani/kit";
117
+ return `import { defineTool } from "@waniwani/kit";
142
118
  import { z } from "zod";
143
119
 
144
120
  /**
@@ -190,9 +166,8 @@ export default defineTool({
190
166
  });
191
167
  `;
192
168
  }
193
-
194
169
  function widgetContract() {
195
- return `import { defineWidget } from "@waniwani/kit";
170
+ return `import { defineWidget } from "@waniwani/kit";
196
171
  import { z } from "zod";
197
172
 
198
173
  const product = z.object({
@@ -229,9 +204,8 @@ Wait for the shopper to pick one, then answer about that product.\`,
229
204
  });
230
205
  `;
231
206
  }
232
-
233
207
  function widgetUi() {
234
- return `import { useLayout, useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
208
+ return `import { useLayout, useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
235
209
  import widget from "./widget.js";
236
210
 
237
211
  const euros = (value: number) =>
@@ -284,26 +258,23 @@ export default function ProductList() {
284
258
  }
285
259
  `;
286
260
  }
287
-
288
261
  function envExample() {
289
- return `# Optional. Without it the app still runs: flows use MemoryKvStore and
262
+ return `# Optional. Without it the app still runs: flows use MemoryKvStore and
290
263
  # withWaniwani degrades to a no-op. With it, flow state is hosted and tracking
291
264
  # reaches app.waniwani.ai.
292
265
  WANIWANI_API_KEY=
293
266
  WANIWANI_PUBLIC_KEY=
294
267
  `;
295
268
  }
296
-
297
269
  function gitignore() {
298
- return `node_modules/
270
+ return `node_modules/
299
271
  .waniwani/
300
272
  .env
301
273
  .env.local
302
274
  `;
303
275
  }
304
-
305
276
  function readme(app) {
306
- return `# ${app.title}
277
+ return `# ${app.title}
307
278
 
308
279
  An MCP app built with [@waniwani/kit](https://www.npmjs.com/package/@waniwani/kit).
309
280
  You own the folders below. The server, the transport, the bundling and the deploy
@@ -329,7 +300,6 @@ npm run start # run the production build
329
300
  and it stays out of git.
330
301
  `;
331
302
  }
332
-
333
303
  /**
334
304
  * The files a new app gets.
335
305
  *
@@ -338,27 +308,25 @@ and it stays out of git.
338
308
  * scaffold's contribution into what is there, and `keep` leaves it untouched.
339
309
  */
340
310
  function scaffold(app, { minimal }) {
341
- const files = [
342
- { path: "package.json", contents: packageJson(app), whenPresent: "merge", merge: mergePackageJson },
343
- { path: ".gitignore", contents: gitignore(), whenPresent: "merge", merge: mergeGitignore },
344
- { path: ".env.example", contents: envExample(), whenPresent: "keep" },
345
- { path: "README.md", contents: readme(app), whenPresent: "keep" },
346
- { path: "waniwani.config.ts", contents: appConfig(app) },
347
- { path: `tools/${TOOL}.ts`, contents: tool() },
348
- ];
349
-
350
- if (!minimal) {
351
- files.push(
352
- { path: `widgets/${WIDGET}/widget.ts`, contents: widgetContract() },
353
- { path: `widgets/${WIDGET}/ui.tsx`, contents: widgetUi() },
354
- );
355
- }
356
-
357
- return files;
311
+ const files = [
312
+ {
313
+ path: "package.json",
314
+ contents: packageJson(app),
315
+ whenPresent: "merge",
316
+ merge: mergePackageJson,
317
+ },
318
+ { path: ".gitignore", contents: gitignore(), whenPresent: "merge", merge: mergeGitignore },
319
+ { path: ".env.example", contents: envExample(), whenPresent: "keep" },
320
+ { path: "README.md", contents: readme(app), whenPresent: "keep" },
321
+ { path: "waniwani.config.ts", contents: appConfig(app) },
322
+ { path: `tools/${TOOL}.ts`, contents: tool() },
323
+ ];
324
+ if (!minimal) {
325
+ files.push({ path: `widgets/${WIDGET}/widget.ts`, contents: widgetContract() }, { path: `widgets/${WIDGET}/ui.tsx`, contents: widgetUi() });
326
+ }
327
+ return files;
358
328
  }
359
-
360
329
  // -------------------------------------------------------------------- merging
361
-
362
330
  /**
363
331
  * Add the scripts and dependencies an app needs to a manifest that is already
364
332
  * there, and touch nothing else. A key the repo already declares is the repo's
@@ -367,36 +335,34 @@ function scaffold(app, { minimal }) {
367
335
  * @returns the keys added, for the CLI to report
368
336
  */
369
337
  function mergePackageJson(file, contents) {
370
- const existing = JSON.parse(readFileSync(file, "utf-8"));
371
- const generated = JSON.parse(contents);
372
- const added = [];
373
-
374
- const fold = (section) => {
375
- const merged = { ...existing[section] };
376
- for (const [key, value] of Object.entries(generated[section])) {
377
- if (merged[key]) continue;
378
- merged[key] = value;
379
- added.push(`${section}.${key}`);
380
- }
381
- return merged;
382
- };
383
-
384
- const next = {
385
- ...existing,
386
- // App modules are ESM. A repo that says commonjs is warned about instead of
387
- // rewritten, since flipping it changes how the rest of that repo loads.
388
- type: existing.type ?? "module",
389
- scripts: fold("scripts"),
390
- dependencies: fold("dependencies"),
391
- };
392
- if (!existing.type) added.push("type");
393
-
394
- if (added.length > 0) {
395
- writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
396
- }
397
- return added;
338
+ const existing = JSON.parse(readFileSync(file, "utf-8"));
339
+ const generated = JSON.parse(contents);
340
+ const added = [];
341
+ const fold = (section) => {
342
+ const merged = { ...existing[section] };
343
+ for (const [key, value] of Object.entries(generated[section] ?? {})) {
344
+ if (merged[key])
345
+ continue;
346
+ merged[key] = value;
347
+ added.push(`${section}.${key}`);
348
+ }
349
+ return merged;
350
+ };
351
+ const next = {
352
+ ...existing,
353
+ // App modules are ESM. A repo that says commonjs is warned about instead of
354
+ // rewritten, since flipping it changes how the rest of that repo loads.
355
+ type: existing.type ?? "module",
356
+ scripts: fold("scripts"),
357
+ dependencies: fold("dependencies"),
358
+ };
359
+ if (!existing.type)
360
+ added.push("type");
361
+ if (added.length > 0) {
362
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
363
+ }
364
+ return added;
398
365
  }
399
-
400
366
  /**
401
367
  * Add the lines the app does not ignore yet, leaving every line it wrote alone.
402
368
  * A trailing slash is not part of the comparison, so a repo ignoring
@@ -405,38 +371,34 @@ function mergePackageJson(file, contents) {
405
371
  * @returns the lines added, for the CLI to report
406
372
  */
407
373
  function mergeGitignore(file, contents) {
408
- const existing = readFileSync(file, "utf-8");
409
- const bare = (line) => line.trim().replace(/\/$/, "");
410
- const known = new Set(existing.split("\n").map(bare));
411
-
412
- const additions = contents
413
- .split("\n")
414
- .filter((line) => line.trim() && !line.trim().startsWith("#") && !known.has(bare(line)));
415
-
416
- if (additions.length === 0) return [];
417
- const prefix = !existing || existing.endsWith("\n") ? "" : "\n";
418
- writeFileSync(file, `${existing}${prefix}${additions.join("\n")}\n`);
419
- return additions;
374
+ const existing = readFileSync(file, "utf-8");
375
+ const bare = (line) => line.trim().replace(/\/$/, "");
376
+ const known = new Set(existing.split("\n").map(bare));
377
+ const additions = contents
378
+ .split("\n")
379
+ .filter((line) => line.trim() && !line.trim().startsWith("#") && !known.has(bare(line)));
380
+ if (additions.length === 0)
381
+ return [];
382
+ const prefix = !existing || existing.endsWith("\n") ? "" : "\n";
383
+ writeFileSync(file, `${existing}${prefix}${additions.join("\n")}\n`);
384
+ return additions;
420
385
  }
421
-
422
386
  // ------------------------------------------------------------------- the shell
423
-
424
387
  function write(file, contents) {
425
- mkdirSync(dirname(file), { recursive: true });
426
- writeFileSync(file, contents);
388
+ mkdirSync(dirname(file), { recursive: true });
389
+ writeFileSync(file, contents);
427
390
  }
428
-
429
391
  /** One question, with the default in parentheses and Enter taking it. */
430
392
  async function ask(question, fallback) {
431
- const rl = createInterface({ input: process.stdin, output: process.stdout });
432
- try {
433
- const answer = await rl.question(`${question} ${dim(`(${fallback})`)} `);
434
- return answer.trim() || fallback;
435
- } finally {
436
- rl.close();
437
- }
393
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
394
+ try {
395
+ const answer = await rl.question(`${question} ${dim(`(${fallback})`)} `);
396
+ return answer.trim() || fallback;
397
+ }
398
+ finally {
399
+ rl.close();
400
+ }
438
401
  }
439
-
440
402
  /**
441
403
  * The package manager that invoked this command, which npm, pnpm, yarn and bun
442
404
  * all announce in `npm_config_user_agent`. Nothing to detect from lockfiles: a
@@ -444,27 +406,30 @@ async function ask(question, fallback) {
444
406
  * typed with one of the four.
445
407
  */
446
408
  function packageManager() {
447
- const agent = process.env.npm_config_user_agent ?? "";
448
- return ["bun", "pnpm", "yarn", "npm"].find((name) => agent.startsWith(name)) ?? "npm";
409
+ const agent = process.env.npm_config_user_agent ?? "";
410
+ const names = ["bun", "pnpm", "yarn", "npm"];
411
+ return names.find((name) => agent.startsWith(name)) ?? "npm";
449
412
  }
450
-
451
413
  /** How each manager runs a binary out of node_modules, for the closing lines. */
452
- const RUNNERS = { npm: "npx", pnpm: "pnpm", yarn: "yarn", bun: "bunx" };
453
-
414
+ const RUNNERS = {
415
+ npm: "npx",
416
+ pnpm: "pnpm",
417
+ yarn: "yarn",
418
+ bun: "bunx",
419
+ };
454
420
  function install(root, manager) {
455
- console.log(`\n${dim(`installing with ${manager}…`)}`);
456
- return new Promise((resolvePromise) => {
457
- const child = spawn(manager, ["install"], {
458
- cwd: root,
459
- stdio: "inherit",
460
- // npm and yarn are .cmd shims on Windows, which execvp cannot run.
461
- shell: process.platform === "win32",
462
- });
463
- child.on("close", (code) => resolvePromise(code === 0));
464
- child.on("error", () => resolvePromise(false));
465
- });
421
+ console.log(`\n${dim(`installing with ${manager}…`)}`);
422
+ return new Promise((resolvePromise) => {
423
+ const child = spawn(manager, ["install"], {
424
+ cwd: root,
425
+ stdio: "inherit",
426
+ // npm and yarn are .cmd shims on Windows, which execvp cannot run.
427
+ shell: process.platform === "win32",
428
+ });
429
+ child.on("close", (code) => resolvePromise(code === 0));
430
+ child.on("error", () => resolvePromise(false));
431
+ });
466
432
  }
467
-
468
433
  /**
469
434
  * Scaffold an app folder, install its dependencies, and say what to run.
470
435
  *
@@ -474,109 +439,96 @@ function install(root, manager) {
474
439
  * @returns a process exit code
475
440
  */
476
441
  export async function init(appRoot, flags, { targeted = true } = {}) {
477
- const interactive = process.stdin.isTTY && !flags.yes && !flags.name;
478
- const suggested = slugify(basename(appRoot));
479
-
480
- // One question, and both fields come out of the answer: `Acme Shop` gives the
481
- // server the name `acme-shop` and keeps `Acme Shop` as the title. An answer
482
- // that is already a slug gets a title with a capital on the front.
483
- const answer = flags.name ?? (interactive ? await ask("App name", suggested) : suggested);
484
- const name = slugify(answer);
485
- const typed = cleanTitle(answer);
486
- const app = { name, title: typed && typed !== name ? typed : titleize(name) };
487
-
488
- // A name typed at the prompt with no directory to put it in names the
489
- // directory as well: `oney` in ~/Projects means ~/Projects/oney, which is how
490
- // create-next-app's one question reads. Taking the offered default leaves
491
- // everything where it is, since that default is the current folder's own name,
492
- // and `waniwani init .` names the current folder outright.
493
- const root = !targeted && interactive && name !== suggested ? join(appRoot, name) : appRoot;
494
-
495
- const files = scaffold(app, { minimal: Boolean(flags.minimal) });
496
-
497
- // Nothing is written until every collision is known, so a refusal leaves the
498
- // directory exactly as it was.
499
- const clashes = files.filter((file) => !file.whenPresent && existsSync(join(root, file.path)));
500
- if (clashes.length > 0 && !flags.force) {
501
- console.error(`\n${red("✗")} ${bold("already an app folder here")}\n`);
502
- for (const file of clashes) {
503
- console.error(` ${file.path}`);
504
- }
505
- console.error(`\n${dim("pass --force to overwrite, or init into a new directory")}`);
506
- return 1;
507
- }
508
-
509
- mkdirSync(root, { recursive: true });
510
-
511
- const actions = [];
512
- for (const file of files) {
513
- const target = join(root, file.path);
514
-
515
- if (!existsSync(target)) {
516
- write(target, file.contents);
517
- actions.push([green("+"), file.path, null]);
518
- continue;
519
- }
520
- if (file.whenPresent === "keep") {
521
- actions.push([dim("·"), file.path, "yours, left alone"]);
522
- continue;
523
- }
524
- if (file.whenPresent === "merge") {
525
- const changed = file.merge(target, file.contents);
526
- actions.push([
527
- changed.length > 0 ? yellow("~") : dim("·"),
528
- file.path,
529
- changed.length > 0 ? `+ ${changed.join(", ")}` : "nothing to add",
530
- ]);
531
- continue;
532
- }
533
- // A collision the caller chose to overwrite.
534
- write(target, file.contents);
535
- actions.push([yellow("~"), file.path, "overwritten"]);
536
- }
537
-
538
- console.log(`\n${green("✓")} ${bold(app.name)} ${dim(`→ ${root}`)}\n`);
539
- for (const [marker, path, note] of actions) {
540
- console.log(` ${marker} ${path}${note ? ` ${dim(note)}` : ""}`);
541
- }
542
-
543
- // The merge leaves a script the repo already had alone, so the way into the dev
544
- // loop is whatever survived that: `npm run dev` when it is ours, the CLI by
545
- // name when the repo's own `dev` runs something else.
546
- const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
547
- const ours = manifest.scripts?.dev === "waniwani dev";
548
-
549
- if (manifest.type !== "module") {
550
- console.log(
551
- `\n${yellow("!")} ${bold("package.json")} says ${bold(`"type": "${manifest.type}"`)}`,
552
- );
553
- console.log(` ${dim('app modules are ESM: set it to "module" or the build cannot load them')}`);
554
- }
555
-
556
- const manager = packageManager();
557
- // A scaffold with no node_modules still checks out and still reads, so a
558
- // failed install is reported and the folder is kept.
559
- const installed = flags.install === false ? null : await install(root, manager);
560
-
561
- console.log(`\n${bold("From here")}`);
562
- // `init apps/store` is scaffolded two levels down, so the path is the one to
563
- // print rather than the directory's own name.
564
- const from = relative(process.cwd(), root);
565
- if (from) {
566
- console.log(` cd ${from}`);
567
- }
568
- if (installed === false) {
569
- console.log(` ${yellow(`${manager} install`)} ${dim("(the first attempt failed)")}`);
570
- } else if (installed === null) {
571
- console.log(` ${manager} install`);
572
- }
573
- console.log(` ${ours ? `${manager} run dev` : `${RUNNERS[manager]} waniwani dev`}`);
574
-
575
- console.log(`\n${bold("Then")}`);
576
- console.log(` ${dim("·")} edit ${bold(`tools/${TOOL}.ts`)} to answer with your own data`);
577
- if (!flags.minimal) {
578
- console.log(` ${dim("·")} edit ${bold(`widgets/${WIDGET}/ui.tsx`)} for how it looks on screen`);
579
- }
580
- console.log(` ${dim("·")} add ${bold("flows/<name>.ts")} for a multi-step conversation`);
581
- return 0;
442
+ const interactive = process.stdin.isTTY && !flags.yes && !flags.name;
443
+ const suggested = slugify(basename(appRoot));
444
+ // One question, and both fields come out of the answer: `Acme Shop` gives the
445
+ // server the name `acme-shop` and keeps `Acme Shop` as the title. An answer
446
+ // that is already a slug gets a title with a capital on the front.
447
+ const answer = flags.name ?? (interactive ? await ask("App name", suggested) : suggested);
448
+ const name = slugify(answer);
449
+ const typed = cleanTitle(answer);
450
+ const app = { name, title: typed && typed !== name ? typed : titleize(name) };
451
+ // A name typed at the prompt with no directory to put it in names the
452
+ // directory as well: `oney` in ~/Projects means ~/Projects/oney, which is how
453
+ // create-next-app's one question reads. Taking the offered default leaves
454
+ // everything where it is, since that default is the current folder's own name,
455
+ // and `waniwani init .` names the current folder outright.
456
+ const root = !targeted && interactive && name !== suggested ? join(appRoot, name) : appRoot;
457
+ const files = scaffold(app, { minimal: Boolean(flags.minimal) });
458
+ // Nothing is written until every collision is known, so a refusal leaves the
459
+ // directory exactly as it was.
460
+ const clashes = files.filter((file) => !file.whenPresent && existsSync(join(root, file.path)));
461
+ if (clashes.length > 0 && !flags.force) {
462
+ console.error(`\n${red("✗")} ${bold("already an app folder here")}\n`);
463
+ for (const file of clashes) {
464
+ console.error(` ${file.path}`);
465
+ }
466
+ console.error(`\n${dim("pass --force to overwrite, or init into a new directory")}`);
467
+ return 1;
468
+ }
469
+ mkdirSync(root, { recursive: true });
470
+ const actions = [];
471
+ for (const file of files) {
472
+ const target = join(root, file.path);
473
+ if (!existsSync(target)) {
474
+ write(target, file.contents);
475
+ actions.push([green("+"), file.path, null]);
476
+ continue;
477
+ }
478
+ if (file.whenPresent === "keep") {
479
+ actions.push([dim("·"), file.path, "yours, left alone"]);
480
+ continue;
481
+ }
482
+ if (file.whenPresent === "merge" && file.merge) {
483
+ const changed = file.merge(target, file.contents);
484
+ actions.push([
485
+ changed.length > 0 ? yellow("~") : dim("·"),
486
+ file.path,
487
+ changed.length > 0 ? `+ ${changed.join(", ")}` : "nothing to add",
488
+ ]);
489
+ continue;
490
+ }
491
+ // A collision the caller chose to overwrite.
492
+ write(target, file.contents);
493
+ actions.push([yellow("~"), file.path, "overwritten"]);
494
+ }
495
+ console.log(`\n${green("✓")} ${bold(app.name)} ${dim(`→ ${root}`)}\n`);
496
+ for (const [marker, path, note] of actions) {
497
+ console.log(` ${marker} ${path}${note ? ` ${dim(note)}` : ""}`);
498
+ }
499
+ // The merge leaves a script the repo already had alone, so the way into the dev
500
+ // loop is whatever survived that: `npm run dev` when it is ours, the CLI by
501
+ // name when the repo's own `dev` runs something else.
502
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
503
+ const ours = manifest.scripts?.dev === "waniwani dev";
504
+ if (manifest.type !== "module") {
505
+ console.log(`\n${yellow("!")} ${bold("package.json")} says ${bold(`"type": "${manifest.type}"`)}`);
506
+ console.log(` ${dim('app modules are ESM: set it to "module" or the build cannot load them')}`);
507
+ }
508
+ const manager = packageManager();
509
+ // A scaffold with no node_modules still checks out and still reads, so a
510
+ // failed install is reported and the folder is kept.
511
+ const installed = flags.install === false ? null : await install(root, manager);
512
+ console.log(`\n${bold("From here")}`);
513
+ // `init apps/store` is scaffolded two levels down, so the path is the one to
514
+ // print rather than the directory's own name.
515
+ const from = relative(process.cwd(), root);
516
+ if (from) {
517
+ console.log(` cd ${from}`);
518
+ }
519
+ if (installed === false) {
520
+ console.log(` ${yellow(`${manager} install`)} ${dim("(the first attempt failed)")}`);
521
+ }
522
+ else if (installed === null) {
523
+ console.log(` ${manager} install`);
524
+ }
525
+ console.log(` ${ours ? `${manager} run dev` : `${RUNNERS[manager]} waniwani dev`}`);
526
+ console.log(`\n${bold("Then")}`);
527
+ console.log(` ${dim("·")} edit ${bold(`tools/${TOOL}.ts`)} to answer with your own data`);
528
+ if (!flags.minimal) {
529
+ console.log(` ${dim("·")} edit ${bold(`widgets/${WIDGET}/ui.tsx`)} for how it looks on screen`);
530
+ }
531
+ console.log(` ${dim("·")} add ${bold("flows/<name>.ts")} for a multi-step conversation`);
532
+ return 0;
582
533
  }
534
+ //# sourceMappingURL=init.js.map