@waniwani/kit 0.1.1 → 0.1.4
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.
- package/README.md +121 -22
- package/cli/codegen.mjs +36 -40
- package/cli/env.mjs +37 -0
- package/cli/framework.mjs +0 -1
- package/cli/index.mjs +31 -190
- package/cli/init.mjs +582 -0
- package/cli/log.mjs +5 -4
- package/cli/scan.mjs +42 -14
- package/cli/validate.mjs +81 -2
- package/dist/index.d.ts +40 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +7 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +76 -49
- package/dist/server.js.map +1 -1
- package/package.json +7 -6
- package/src/index.ts +45 -7
- package/src/server.ts +79 -56
- package/cli/account.mjs +0 -264
- package/cli/tunnel.mjs +0 -140
package/cli/init.mjs
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `waniwani init`, the first command anyone runs.
|
|
3
|
+
*
|
|
4
|
+
* What it writes is an app folder that already passes `waniwani check` and
|
|
5
|
+
* already renders something: a config, a tool, and the widget that displays what
|
|
6
|
+
* the tool returned. The point of scaffolding a working pair instead of an empty
|
|
7
|
+
* folder is that the tool-to-widget hand-off is the one piece of this framework
|
|
8
|
+
* nobody guesses correctly from the type signatures.
|
|
9
|
+
*
|
|
10
|
+
* waniwani init my-app create my-app/ and scaffold in it
|
|
11
|
+
* waniwani init . scaffold in the current directory
|
|
12
|
+
* waniwani init ask for a name, and put the app where the
|
|
13
|
+
* answer says: a name of its own creates
|
|
14
|
+
* ./<name>/, the offered default (the current
|
|
15
|
+
* folder's name) scaffolds in place
|
|
16
|
+
*
|
|
17
|
+
* --name <name> the MCP server name, default the directory name
|
|
18
|
+
* --minimal config and one tool, no widget
|
|
19
|
+
* --yes take every default, ask nothing
|
|
20
|
+
* --no-install skip the dependency install
|
|
21
|
+
* --force overwrite app files that are already there
|
|
22
|
+
*
|
|
23
|
+
* Running it inside a repo that already has files is expected and supported.
|
|
24
|
+
* A `package.json` is merged rather than replaced, a `.gitignore` gains the
|
|
25
|
+
* lines it lacks, and a `README.md` or `.env.example` that exists is left
|
|
26
|
+
* alone. Only the app's own source files count as a collision, and those stop
|
|
27
|
+
* the command until `--force` says otherwise.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
33
|
+
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
|
+
|
|
40
|
+
/** A directory name as an MCP server name: `My App` becomes `my-app`. */
|
|
41
|
+
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";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `my-app` becomes `My app`, for the human-facing title. */
|
|
51
|
+
function titleize(slug) {
|
|
52
|
+
const words = slug.replace(/[-_]+/g, " ");
|
|
53
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A title, made safe to drop into the generated TypeScript and Markdown.
|
|
58
|
+
* Backticks, quotes, backslashes and `$` are the characters that would end a
|
|
59
|
+
* string or a template literal early, and a product name needs none of them.
|
|
60
|
+
*/
|
|
61
|
+
function cleanTitle(input) {
|
|
62
|
+
return input
|
|
63
|
+
.replace(/[`"'\\$]/g, "")
|
|
64
|
+
.replace(/\s+/g, " ")
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
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
|
+
/**
|
|
82
|
+
* What a new app depends on.
|
|
83
|
+
*
|
|
84
|
+
* 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.
|
|
89
|
+
*/
|
|
90
|
+
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
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ------------------------------------------------------------ scaffold content
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The tool name and the widget name the scaffold uses. They appear in five
|
|
105
|
+
* files, including inside prose the model reads, so they are named once.
|
|
106
|
+
*/
|
|
107
|
+
const TOOL = "search-products";
|
|
108
|
+
const WIDGET = "product-list";
|
|
109
|
+
|
|
110
|
+
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`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appConfig(app) {
|
|
130
|
+
return `import { defineApp } from "@waniwani/kit";
|
|
131
|
+
|
|
132
|
+
export default defineApp({
|
|
133
|
+
// The MCP server name. Hosts show \`title\` to humans and use this one as the id.
|
|
134
|
+
name: ${JSON.stringify(app.name)},
|
|
135
|
+
title: ${JSON.stringify(app.title)},
|
|
136
|
+
});
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function tool() {
|
|
141
|
+
return `import { defineTool } from "@waniwani/kit";
|
|
142
|
+
import { z } from "zod";
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The filename is the tool name, so this file is \`${TOOL}\`. Rename the
|
|
146
|
+
* file and the tool renames with it.
|
|
147
|
+
*
|
|
148
|
+
* Swap CATALOGUE for whatever answers the question for real: a fetch, a
|
|
149
|
+
* database, an internal API. \`run\` may be async.
|
|
150
|
+
*/
|
|
151
|
+
const CATALOGUE = [
|
|
152
|
+
{ id: "aeron", name: "Aeron chair", price: 1290, blurb: "Mesh task chair, twelve-year warranty." },
|
|
153
|
+
{ id: "sayl", name: "Sayl chair", price: 545, blurb: "Suspension back, the light one." },
|
|
154
|
+
{ id: "nevi", name: "Nevi sit-stand desk", price: 890, blurb: "Electric, 70 to 120 cm." },
|
|
155
|
+
{ id: "ollin", name: "Ollin monitor arm", price: 235, blurb: "Single arm, holds up to 9 kg." },
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
export default defineTool({
|
|
159
|
+
// Shown to humans in connector UIs.
|
|
160
|
+
title: "Search the catalogue",
|
|
161
|
+
// The only thing the model reads before deciding to call this, so it says
|
|
162
|
+
// when to call it and what not to do instead.
|
|
163
|
+
description:
|
|
164
|
+
"Find products matching what the shopper asked for. Call this before naming any product or quoting any price, and never answer either from memory. Pass the shopper's own words as the query.",
|
|
165
|
+
// Zod shapes, written as plain objects instead of z.object({ ... }).
|
|
166
|
+
input: {
|
|
167
|
+
query: z.string().describe("What the shopper asked for, in their words, e.g. 'a chair under 600'."),
|
|
168
|
+
},
|
|
169
|
+
output: {
|
|
170
|
+
products: z.array(
|
|
171
|
+
z.object({
|
|
172
|
+
id: z.string(),
|
|
173
|
+
name: z.string(),
|
|
174
|
+
price: z.number().describe("Price in euros."),
|
|
175
|
+
blurb: z.string(),
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
// Becomes MCP annotations. This tool reads and does nothing else.
|
|
180
|
+
hints: { readOnly: true },
|
|
181
|
+
run: ({ query }) => {
|
|
182
|
+
const terms = query.toLowerCase().split(/\\s+/).filter(Boolean);
|
|
183
|
+
const matched = CATALOGUE.filter((product) =>
|
|
184
|
+
terms.some((term) => \`\${product.name} \${product.blurb}\`.toLowerCase().includes(term)),
|
|
185
|
+
);
|
|
186
|
+
// The whole catalogue when nothing matched, so an early conversation has
|
|
187
|
+
// something on screen while you are still wiring this up.
|
|
188
|
+
return { products: matched.length > 0 ? matched : CATALOGUE };
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function widgetContract() {
|
|
195
|
+
return `import { defineWidget } from "@waniwani/kit";
|
|
196
|
+
import { z } from "zod";
|
|
197
|
+
|
|
198
|
+
const product = z.object({
|
|
199
|
+
id: z.string(),
|
|
200
|
+
name: z.string(),
|
|
201
|
+
price: z.number().describe("Price in euros."),
|
|
202
|
+
blurb: z.string().describe("One line about the product."),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The folder name is the tool name, so this widget is \`${WIDGET}\`.
|
|
207
|
+
*
|
|
208
|
+
* \`data\` is one schema doing three jobs: the tool's input, its structured
|
|
209
|
+
* output, and the props \`useWidget()\` hands ui.tsx. Server and UI cannot drift.
|
|
210
|
+
*
|
|
211
|
+
* This file is imported by the server and by the browser bundle, so it stays
|
|
212
|
+
* free of React and CSS. The component sits next to it in ui.tsx.
|
|
213
|
+
*/
|
|
214
|
+
export default defineWidget({
|
|
215
|
+
title: "Product list",
|
|
216
|
+
description:
|
|
217
|
+
"Show the product cards. Call this once ${TOOL} has returned products, passing them through unmodified. Frame it in one short sentence before calling, e.g. \\"Here's what fits.\\" The widget renders every name and price itself, so do NOT list them in text.",
|
|
218
|
+
data: {
|
|
219
|
+
query: z.string().describe("What the shopper asked for. Shown as the heading."),
|
|
220
|
+
products: z.array(product).describe("Products returned by ${TOOL}, unmodified."),
|
|
221
|
+
},
|
|
222
|
+
hints: { readOnly: true },
|
|
223
|
+
// Text handed to the model alongside the rendered widget. Use it to say what
|
|
224
|
+
// the model should not repeat, and what it should wait for.
|
|
225
|
+
llmText: (data) =>
|
|
226
|
+
\`The product list is on screen with \${data.products.length} products. It renders every name and price itself, so do NOT repeat them in text.
|
|
227
|
+
|
|
228
|
+
Wait for the shopper to pick one, then answer about that product.\`,
|
|
229
|
+
});
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function widgetUi() {
|
|
234
|
+
return `import { useLayout, useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
|
|
235
|
+
import widget from "./widget.js";
|
|
236
|
+
|
|
237
|
+
const euros = (value: number) =>
|
|
238
|
+
new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }).format(value);
|
|
239
|
+
|
|
240
|
+
export default function ProductList() {
|
|
241
|
+
// Typed off the widget's own \`data\` schema. No generated helpers, no server
|
|
242
|
+
// type import.
|
|
243
|
+
const { data } = useWidget(widget);
|
|
244
|
+
const sendFollowUp = useSendFollowUpMessage();
|
|
245
|
+
|
|
246
|
+
// The host hands the colour scheme to the view instead of to the browser, so
|
|
247
|
+
// \`prefers-color-scheme\` is the wrong signal and Tailwind's \`dark:\` variant is
|
|
248
|
+
// wired to a \`dark\` class (see the template's src/index.css). Every widget puts
|
|
249
|
+
// that class on its own root: a view is its own bundle in its own iframe, so
|
|
250
|
+
// there is no shared ancestor to hang it off.
|
|
251
|
+
const { theme } = useLayout();
|
|
252
|
+
const root = theme === "dark" ? "dark" : "";
|
|
253
|
+
|
|
254
|
+
// \`data\` arrives as soon as the host has the tool input, which on most hosts is
|
|
255
|
+
// before the server has responded. Render optimistically.
|
|
256
|
+
if (!data) {
|
|
257
|
+
return <div className={\`\${root} font-sans text-sm text-slate-500\`}>Loading…</div>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className={\`\${root} font-sans text-slate-900 dark:text-slate-100\`}>
|
|
262
|
+
<h1 className="mb-3 text-lg font-semibold tracking-tight">{data.query}</h1>
|
|
263
|
+
|
|
264
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2.5">
|
|
265
|
+
{data.products.map((product) => (
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
key={product.id}
|
|
269
|
+
// A click becomes a message from the shopper, which is what moves
|
|
270
|
+
// the conversation on.
|
|
271
|
+
onClick={() => sendFollowUp(\`Tell me more about the \${product.name}.\`)}
|
|
272
|
+
className="flex cursor-pointer flex-col items-start gap-1 rounded-2xl border-[1.5px] border-slate-200 bg-white p-3.5 text-left transition duration-150 hover:-translate-y-px hover:border-slate-400 hover:shadow-lg hover:shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-500"
|
|
273
|
+
// What the model reads in place of the pixels.
|
|
274
|
+
data-llm={\`\${product.name}, \${euros(product.price)}: \${product.blurb}\`}
|
|
275
|
+
>
|
|
276
|
+
<span className="text-[22px] font-bold tracking-tight">{euros(product.price)}</span>
|
|
277
|
+
<span className="font-semibold">{product.name}</span>
|
|
278
|
+
<span className="text-[13px] text-slate-500 dark:text-slate-400">{product.blurb}</span>
|
|
279
|
+
</button>
|
|
280
|
+
))}
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function envExample() {
|
|
289
|
+
return `# Optional. Without it the app still runs: flows use MemoryKvStore and
|
|
290
|
+
# withWaniwani degrades to a no-op. With it, flow state is hosted and tracking
|
|
291
|
+
# reaches app.waniwani.ai.
|
|
292
|
+
WANIWANI_API_KEY=
|
|
293
|
+
WANIWANI_PUBLIC_KEY=
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function gitignore() {
|
|
298
|
+
return `node_modules/
|
|
299
|
+
.waniwani/
|
|
300
|
+
.env
|
|
301
|
+
.env.local
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readme(app) {
|
|
306
|
+
return `# ${app.title}
|
|
307
|
+
|
|
308
|
+
An MCP app built with [@waniwani/kit](https://www.npmjs.com/package/@waniwani/kit).
|
|
309
|
+
You own the folders below. The server, the transport, the bundling and the deploy
|
|
310
|
+
files are the kit's.
|
|
311
|
+
|
|
312
|
+
\`\`\`
|
|
313
|
+
waniwani.config.ts the app's name and title, plus optional instructions
|
|
314
|
+
tools/*.ts one file per tool; the filename is the tool name
|
|
315
|
+
widgets/<name>/ widget.ts for the contract, ui.tsx for the component
|
|
316
|
+
flows/*.ts multi-step conversations, from @waniwani/sdk
|
|
317
|
+
\`\`\`
|
|
318
|
+
|
|
319
|
+
## Commands
|
|
320
|
+
|
|
321
|
+
\`\`\`bash
|
|
322
|
+
npm run dev # dev server, regenerating on every change
|
|
323
|
+
npm run check # validate the folder without building
|
|
324
|
+
npm run build # production build
|
|
325
|
+
npm run start # run the production build
|
|
326
|
+
\`\`\`
|
|
327
|
+
|
|
328
|
+
\`.waniwani/\` is build output, the way \`.next/\` is. Every command regenerates it
|
|
329
|
+
and it stays out of git.
|
|
330
|
+
`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The files a new app gets.
|
|
335
|
+
*
|
|
336
|
+
* `whenPresent` decides what happens to one that is already on disk:
|
|
337
|
+
* `undefined` is a collision that stops the command, `merge` folds the
|
|
338
|
+
* scaffold's contribution into what is there, and `keep` leaves it untouched.
|
|
339
|
+
*/
|
|
340
|
+
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;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// -------------------------------------------------------------------- merging
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Add the scripts and dependencies an app needs to a manifest that is already
|
|
364
|
+
* there, and touch nothing else. A key the repo already declares is the repo's
|
|
365
|
+
* decision, including a `dev` script that runs something other than this CLI.
|
|
366
|
+
*
|
|
367
|
+
* @returns the keys added, for the CLI to report
|
|
368
|
+
*/
|
|
369
|
+
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;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Add the lines the app does not ignore yet, leaving every line it wrote alone.
|
|
402
|
+
* A trailing slash is not part of the comparison, so a repo ignoring
|
|
403
|
+
* `node_modules` does not gain `node_modules/` next to it.
|
|
404
|
+
*
|
|
405
|
+
* @returns the lines added, for the CLI to report
|
|
406
|
+
*/
|
|
407
|
+
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;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ------------------------------------------------------------------- the shell
|
|
423
|
+
|
|
424
|
+
function write(file, contents) {
|
|
425
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
426
|
+
writeFileSync(file, contents);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** One question, with the default in parentheses and Enter taking it. */
|
|
430
|
+
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
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The package manager that invoked this command, which npm, pnpm, yarn and bun
|
|
442
|
+
* all announce in `npm_config_user_agent`. Nothing to detect from lockfiles: a
|
|
443
|
+
* new folder has none, and a `waniwani init` inside an existing repo was still
|
|
444
|
+
* typed with one of the four.
|
|
445
|
+
*/
|
|
446
|
+
function packageManager() {
|
|
447
|
+
const agent = process.env.npm_config_user_agent ?? "";
|
|
448
|
+
return ["bun", "pnpm", "yarn", "npm"].find((name) => agent.startsWith(name)) ?? "npm";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 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
|
+
|
|
454
|
+
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
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Scaffold an app folder, install its dependencies, and say what to run.
|
|
470
|
+
*
|
|
471
|
+
* @param appRoot the directory to scaffold, created if it does not exist
|
|
472
|
+
* @param flags parsed CLI flags
|
|
473
|
+
* @param options.targeted a directory was named on the command line
|
|
474
|
+
* @returns a process exit code
|
|
475
|
+
*/
|
|
476
|
+
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;
|
|
582
|
+
}
|
package/cli/log.mjs
CHANGED
|
@@ -150,7 +150,7 @@ export function printReport(app, report) {
|
|
|
150
150
|
[app.widgets.length, "widget"],
|
|
151
151
|
[app.tools.length, "tool"],
|
|
152
152
|
[app.flows.length, "flow"],
|
|
153
|
-
[app.
|
|
153
|
+
[app.endpoints.length, "endpoint"],
|
|
154
154
|
]
|
|
155
155
|
.filter(([count]) => count > 0)
|
|
156
156
|
.map(([count, label]) => `${count} ${label}${count === 1 ? "" : "s"}`);
|
|
@@ -166,10 +166,11 @@ export function printReport(app, report) {
|
|
|
166
166
|
for (const flow of app.flows) {
|
|
167
167
|
console.log(` ${dim("flow ")} ${flow.name}`);
|
|
168
168
|
}
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
// The path, not the filename: what a widget writes into a `fetch()` is the
|
|
170
|
+
// thing worth checking against this line.
|
|
171
|
+
for (const endpoint of app.endpoints) {
|
|
172
|
+
console.log(` ${dim("api ")} ${endpoint.path}`);
|
|
171
173
|
}
|
|
172
|
-
|
|
173
174
|
if (report.warnings.length > 0) {
|
|
174
175
|
console.log("");
|
|
175
176
|
printGroup(report.warnings, "└", yellow);
|
package/cli/scan.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* tools/<name>.ts
|
|
6
6
|
* widgets/<name>/{widget.ts,ui.tsx}
|
|
7
7
|
* flows/<name>.ts
|
|
8
|
-
*
|
|
8
|
+
* api/<path>.ts
|
|
9
9
|
*
|
|
10
10
|
* There is no CSS in that list. Styling is Tailwind, from the distribution
|
|
11
11
|
* template's `src/index.css` — its `@theme` tokens and its `dark` variant — and
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* collected only so the build check can tell an author they are dead.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, readdirSync,
|
|
16
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
17
17
|
import { basename, extname, join } from "node:path";
|
|
18
18
|
|
|
19
19
|
const CODE_EXT = new Set([".ts", ".tsx", ".mts"]);
|
|
@@ -38,10 +38,40 @@ function stripExt(path) {
|
|
|
38
38
|
return basename(path, extname(path));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Every code file under `dir`, depth first, as `{ file, segments }` where
|
|
43
|
+
* `segments` is its path below `dir` with the extension gone.
|
|
44
|
+
*
|
|
45
|
+
* `api/` is the one convention folder that nests: an HTTP path has more than
|
|
46
|
+
* one segment, and the only place it can come from without a registry is the
|
|
47
|
+
* filesystem.
|
|
48
|
+
*/
|
|
49
|
+
function listTree(dir, trail = []) {
|
|
50
|
+
if (!existsSync(dir)) return [];
|
|
51
|
+
|
|
52
|
+
return readdirSync(dir)
|
|
53
|
+
.filter((entry) => !entry.startsWith(".") && !entry.startsWith("_"))
|
|
54
|
+
.flatMap((entry) => {
|
|
55
|
+
const path = join(dir, entry);
|
|
56
|
+
if (statSync(path).isDirectory()) {
|
|
57
|
+
return listTree(path, [...trail, entry]);
|
|
58
|
+
}
|
|
59
|
+
if (!CODE_EXT.has(extname(path))) return [];
|
|
60
|
+
return [{ file: path, segments: [...trail, stripExt(path)] }];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The URL an endpoint file is served at: its position under the app root,
|
|
66
|
+
* `/api` included, since that is the folder's name.
|
|
67
|
+
*
|
|
68
|
+
* `index` names the directory itself, so `api/cal/index.ts` answers `/api/cal`
|
|
69
|
+
* — the one place a filename is not taken verbatim, and the convention every
|
|
70
|
+
* web framework already uses.
|
|
71
|
+
*/
|
|
72
|
+
function endpointPath(segments) {
|
|
73
|
+
const parts = segments.at(-1) === "index" ? segments.slice(0, -1) : segments;
|
|
74
|
+
return `/api/${parts.join("/")}`.replace(/\/$/, "") || "/api";
|
|
45
75
|
}
|
|
46
76
|
|
|
47
77
|
export function scanApp(root) {
|
|
@@ -64,13 +94,11 @@ export function scanApp(root) {
|
|
|
64
94
|
.filter((file) => CODE_EXT.has(extname(file)))
|
|
65
95
|
.map((file) => ({ name: stripExt(file), file }));
|
|
66
96
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return { slug, file, title: docTitle(body, slug), body };
|
|
73
|
-
});
|
|
97
|
+
const endpoints = listTree(join(root, "api")).map(({ file, segments }) => ({
|
|
98
|
+
path: endpointPath(segments),
|
|
99
|
+
segments,
|
|
100
|
+
file,
|
|
101
|
+
}));
|
|
74
102
|
|
|
75
103
|
// The two paths an author is most likely to expect the kit to pick up. It
|
|
76
104
|
// imports neither, so a file at either one is styling that never reaches the
|
|
@@ -80,5 +108,5 @@ export function scanApp(root) {
|
|
|
80
108
|
...widgets.map((widget) => join(widget.dir, "styles.css")),
|
|
81
109
|
].filter(existsSync);
|
|
82
110
|
|
|
83
|
-
return { root, configFile, tools, widgets, flows,
|
|
111
|
+
return { root, configFile, tools, widgets, flows, endpoints, strayStyles };
|
|
84
112
|
}
|