@waniwani/kit 0.1.1 → 0.1.3
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 +31 -22
- package/cli/codegen.mjs +12 -32
- package/cli/framework.mjs +0 -1
- package/cli/index.mjs +24 -190
- package/cli/init.mjs +582 -0
- package/cli/log.mjs +0 -5
- package/cli/scan.mjs +2 -17
- package/cli/validate.mjs +2 -2
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +2 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -50
- package/dist/server.js.map +1 -1
- package/package.json +2 -6
- package/src/index.ts +0 -10
- package/src/server.ts +3 -62
- package/cli/account.mjs +0 -264
- package/cli/tunnel.mjs +0 -140
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @waniwani/kit
|
|
2
2
|
|
|
3
|
-
**Build an MCP app as a folder.** You write tools, widgets
|
|
3
|
+
**Build an MCP app as a folder.** You write tools, widgets and flows into
|
|
4
4
|
a directory, and one CLI turns that directory into a deployable MCP server. Your
|
|
5
5
|
repo holds none of the plumbing: server bootstrap, transport wiring, build
|
|
6
6
|
configuration.
|
|
@@ -15,8 +15,7 @@ oney/ # what you write
|
|
|
15
15
|
├── waniwani.config.ts
|
|
16
16
|
├── tools/check-eligibility.ts
|
|
17
17
|
├── widgets/select-plan/{widget.ts,ui.tsx}
|
|
18
|
-
|
|
19
|
-
└── docs/*.md
|
|
18
|
+
└── flows/split-payment.ts
|
|
20
19
|
|
|
21
20
|
waniwani build # → .waniwani/, an ordinary npm project
|
|
22
21
|
```
|
|
@@ -82,9 +81,25 @@ registers them come from the kit.
|
|
|
82
81
|
|
|
83
82
|
## Quickstart
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
```bash
|
|
85
|
+
npx @waniwani/kit init oney
|
|
86
|
+
cd oney && npm run dev
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`init` writes a folder that already answers: an app config, one tool, and the
|
|
90
|
+
widget that displays what the tool returned. It installs, and the dev
|
|
91
|
+
server is one command away. `--minimal` scaffolds the config and the tool alone,
|
|
92
|
+
`--name` sets the MCP server name, and running it inside an existing repo merges
|
|
93
|
+
into that repo's `package.json` and `.gitignore` instead of replacing them.
|
|
94
|
+
|
|
95
|
+
Where the app lands follows the argument. `init oney` creates `oney/`, `init .`
|
|
96
|
+
uses the current folder, and a bare `init` asks for a name and reads the answer
|
|
97
|
+
as both: a name of its own creates `./<name>/`, while the offered default, your
|
|
98
|
+
current folder's name, scaffolds in place.
|
|
99
|
+
|
|
100
|
+
The rest of this section is what those files hold, written out by hand.
|
|
101
|
+
[examples/oney](https://github.com/WaniWani-AI/kit/blob/main/examples/oney) is the same app finished, if you would rather read
|
|
102
|
+
it than type it.
|
|
88
103
|
|
|
89
104
|
```bash
|
|
90
105
|
mkdir oney && cd oney
|
|
@@ -235,9 +250,6 @@ oney/
|
|
|
235
250
|
│ └── ui.tsx export default function Component()
|
|
236
251
|
├── flows/
|
|
237
252
|
│ └── split-payment.ts export default createFlow(...).compile() ← SDK
|
|
238
|
-
├── docs/
|
|
239
|
-
│ ├── fees.md becomes the `search_docs` tool
|
|
240
|
-
│ └── eligibility.md
|
|
241
253
|
└── lib/ anything else is just modules
|
|
242
254
|
```
|
|
243
255
|
|
|
@@ -251,7 +263,6 @@ unwired.
|
|
|
251
263
|
| `tools/<name>.ts` | one MCP tool | `.ts`, `.tsx` and `.mts` are picked up |
|
|
252
264
|
| `widgets/<name>/` | one MCP tool plus a `ui://` resource | needs `widget.ts` and `ui.tsx` |
|
|
253
265
|
| `flows/<name>.ts` | one MCP tool, registered from the SDK unchanged | whatever `.compile()` returns |
|
|
254
|
-
| `docs/*.md` | a single `search_docs` tool over all of them | the first `# heading` becomes the title |
|
|
255
266
|
| anything else | plain modules | the CLI leaves it alone |
|
|
256
267
|
|
|
257
268
|
The app folder imports `@waniwani/kit`, plus `@waniwani/sdk` when it uses flows,
|
|
@@ -392,15 +403,16 @@ letting it sit there doing nothing:
|
|
|
392
403
|
## Commands
|
|
393
404
|
|
|
394
405
|
```bash
|
|
406
|
+
waniwani init [dir] # scaffold an app folder, install, ready to dev
|
|
395
407
|
waniwani check # validate the folder
|
|
396
408
|
waniwani dev # generate + dev server + regenerate on change
|
|
397
409
|
waniwani build # generate + production build
|
|
398
410
|
waniwani start # run the production build
|
|
399
|
-
waniwani deploy # generate + vercel deploy
|
|
400
411
|
waniwani eject [--out dir] # hand the plumbing over and step out
|
|
401
412
|
```
|
|
402
413
|
|
|
403
|
-
|
|
414
|
+
`init` writes files and stops there. Every other command runs the same four
|
|
415
|
+
stages before doing its own work.
|
|
404
416
|
|
|
405
417
|
```mermaid
|
|
406
418
|
flowchart LR
|
|
@@ -409,7 +421,6 @@ flowchart LR
|
|
|
409
421
|
tools["tools/*.ts"]
|
|
410
422
|
widgets["widgets/<name>/<br/>widget.ts + ui.tsx"]
|
|
411
423
|
flows["flows/*.ts"]
|
|
412
|
-
docs["docs/*.md"]
|
|
413
424
|
end
|
|
414
425
|
|
|
415
426
|
subgraph tpl["WaniWani-AI/mcp-distribution-template (public, separate repo)"]
|
|
@@ -433,7 +444,7 @@ flowchart LR
|
|
|
433
444
|
app --> scan --> check --> gen --> out
|
|
434
445
|
runtime -.imported by.-> server
|
|
435
446
|
raw -.fetched at a pinned SHA, copied byte for byte.-> deployfiles
|
|
436
|
-
out --> deploy["dev · build · start
|
|
447
|
+
out --> deploy["dev · build · start"]
|
|
437
448
|
app -.waniwani eject.-> ejected["a plain repo<br/><i>no CLI, no @waniwani/kit</i>"]
|
|
438
449
|
```
|
|
439
450
|
|
|
@@ -513,9 +524,9 @@ under `src/app/`:
|
|
|
513
524
|
|
|
514
525
|
```
|
|
515
526
|
oney/
|
|
516
|
-
├── src/app/{tools,widgets,flows,
|
|
527
|
+
├── src/app/{tools,widgets,flows,lib}/ your code, moved
|
|
517
528
|
├── src/_runtime/ the runtime, vendored as source
|
|
518
|
-
├── src/{server,waniwani
|
|
529
|
+
├── src/{server,waniwani}.ts entry and registration
|
|
519
530
|
├── src/views/<widget>.tsx view entries
|
|
520
531
|
├── vite.config.ts vercel.json alpic.json bundling and deploy
|
|
521
532
|
├── Dockerfile .dockerignore container deploy
|
|
@@ -545,8 +556,6 @@ What an ejected repo gives up is the generator, and with it:
|
|
|
545
556
|
- **the build check**, so `showWidget("typo")` becomes a runtime failure again
|
|
546
557
|
- **name-from-filesystem**, so adding a widget means editing `src/waniwani.ts`
|
|
547
558
|
and adding an entry under `src/views/`
|
|
548
|
-
- **docs auto-scan**, since `src/docs.ts` is a snapshot and new `docs/*.md` need
|
|
549
|
-
hand-wiring
|
|
550
559
|
- **runtime fixes**, since `src/_runtime/` is a fork from the moment it lands
|
|
551
560
|
|
|
552
561
|
## Status
|
|
@@ -555,12 +564,12 @@ What an ejected repo gives up is the generator, and with it:
|
|
|
555
564
|
|
|
556
565
|
- **`@waniwani/cli` owns the `waniwani` bin on npm**, so installing both
|
|
557
566
|
collides.
|
|
558
|
-
-
|
|
559
|
-
|
|
567
|
+
- **`waniwani init` scaffolds one shape of app**, a tool with the widget that
|
|
568
|
+
displays it. A flow is not among the files it writes.
|
|
569
|
+
- **Deploying is manual.** `.waniwani/` carries a `vercel.json`, so
|
|
570
|
+
`vercel deploy` inside it works, but no command wraps that.
|
|
560
571
|
- **`useWidget` does not track yet.** Emitting `widget_render` and click events
|
|
561
572
|
through `useWaniwani` automatically is the next step.
|
|
562
|
-
- **Docs search is a term-match** rather than the hosted KB. Swapping it for
|
|
563
|
-
`wani.kb.search` when `WANIWANI_API_KEY` is set is a runtime change only.
|
|
564
573
|
|
|
565
574
|
Template pinning, the CI contract, publishing requirements and the rest of the
|
|
566
575
|
gap list are in [INTERNALS.md](https://github.com/WaniWani-AI/kit/blob/main/INTERNALS.md).
|
package/cli/codegen.mjs
CHANGED
|
@@ -201,7 +201,7 @@ const SCRIPT_ADDITIONS = {
|
|
|
201
201
|
*/
|
|
202
202
|
const SCRIPT_REMOVALS = {
|
|
203
203
|
"kb:ingest": {
|
|
204
|
-
why: "ingests knowledge-base/, which is the example's; an app
|
|
204
|
+
why: "ingests knowledge-base/, which is the example's; an app has no such folder",
|
|
205
205
|
},
|
|
206
206
|
};
|
|
207
207
|
|
|
@@ -247,9 +247,7 @@ const NOT_SOURCE = new Set([
|
|
|
247
247
|
".skybridge",
|
|
248
248
|
".vercel",
|
|
249
249
|
// The repo's own, not the app's. An in-place eject moves what it copies, and
|
|
250
|
-
// a README that reappears under `src/app/` is a bad surprise.
|
|
251
|
-
// actually serves live in `docs/*.md` and are inlined separately, so nothing
|
|
252
|
-
// is lost by skipping these at every level.
|
|
250
|
+
// a README that reappears under `src/app/` is a bad surprise.
|
|
253
251
|
"README.md",
|
|
254
252
|
"LICENSE",
|
|
255
253
|
// Lockfiles describe the repo's install, and the generated package.json is
|
|
@@ -268,7 +266,7 @@ const NOT_SOURCE = new Set([
|
|
|
268
266
|
* tools in it, and reads `src/waniwani.ts` — the one file this generates into
|
|
269
267
|
* the template's tree.
|
|
270
268
|
*/
|
|
271
|
-
const GENERATED = ["src/waniwani.ts", "
|
|
269
|
+
const GENERATED = ["src/waniwani.ts", "tsconfig.json", ".template.json"];
|
|
272
270
|
|
|
273
271
|
/** `select-plan` -> `selectPlan`, for generated identifiers. */
|
|
274
272
|
function camel(name) {
|
|
@@ -599,7 +597,6 @@ function generateServerApp(app, layout, { runtime, styleDomains }) {
|
|
|
599
597
|
`import { config as loadEnv } from "dotenv";`,
|
|
600
598
|
`import type { McpServer } from "skybridge/server";`,
|
|
601
599
|
`import { registerApp as register } from "${runtime.server}";`,
|
|
602
|
-
app.docs.length > 0 ? `import { docs } from "./docs.js";` : null,
|
|
603
600
|
`import config from "${from}/waniwani.config.js";`,
|
|
604
601
|
...app.tools.map((t) => `import tool_${camel(t.name)} from "${from}/tools/${t.name}.js";`),
|
|
605
602
|
...app.widgets.map(
|
|
@@ -630,7 +627,6 @@ export async function registerApp(server: McpServer): Promise<void> {
|
|
|
630
627
|
tools: ${list(app.tools.map((t) => `{ name: "${t.name}", def: tool_${camel(t.name)} }`))},
|
|
631
628
|
widgets: ${list(app.widgets.map((w) => `{ name: "${w.name}", def: widget_${camel(w.name)} }`))},
|
|
632
629
|
flows: ${list(app.flows.map((f) => `flow_${camel(f.name)}`))},
|
|
633
|
-
docs: ${app.docs.length > 0 ? "docs" : "[]"},
|
|
634
630
|
// Read off the template's ${STYLE_ENTRY}, which every view imports.
|
|
635
631
|
styleDomains: ${list(styleDomains.map((origin) => `"${origin}"`))},
|
|
636
632
|
});
|
|
@@ -638,22 +634,6 @@ export async function registerApp(server: McpServer): Promise<void> {
|
|
|
638
634
|
`;
|
|
639
635
|
}
|
|
640
636
|
|
|
641
|
-
/**
|
|
642
|
-
* Docs are inlined into a module rather than read from disk, so they survive a
|
|
643
|
-
* serverless bundle with no filesystem.
|
|
644
|
-
*/
|
|
645
|
-
function generateDocs(app, { runtime }) {
|
|
646
|
-
return `// Generated from docs/*.md.
|
|
647
|
-
import type { DocEntry } from "${runtime.index}";
|
|
648
|
-
|
|
649
|
-
export const docs: DocEntry[] = ${JSON.stringify(
|
|
650
|
-
app.docs.map((doc) => ({ slug: doc.slug, title: doc.title, body: doc.body })),
|
|
651
|
-
null,
|
|
652
|
-
2,
|
|
653
|
-
)};
|
|
654
|
-
`;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
637
|
function generateWidgetShim(widget, layout) {
|
|
658
638
|
// From `src/views/` up to `src/`, then out to the app's source.
|
|
659
639
|
const from = `../${basename(layout.appDir)}`;
|
|
@@ -740,7 +720,6 @@ function generateBiome(template, layout) {
|
|
|
740
720
|
// Generated and vendored code is not the app author's to fix.
|
|
741
721
|
`!${layout.runtimeDir}/**`,
|
|
742
722
|
"!src/server.ts",
|
|
743
|
-
"!src/docs.ts",
|
|
744
723
|
"!src/views/**",
|
|
745
724
|
...negative,
|
|
746
725
|
],
|
|
@@ -861,7 +840,7 @@ function assertSeam(template) {
|
|
|
861
840
|
|
|
862
841
|
throw new Error(
|
|
863
842
|
`the template at ${template.source} never calls ${SEAM.symbol}(), so this app's\n` +
|
|
864
|
-
` tools, widgets
|
|
843
|
+
` tools, widgets and flows would be built and then silently dropped.\n\n` +
|
|
865
844
|
` Add to its ${SEAM.file}:\n\n` +
|
|
866
845
|
` import { app, registerApp } from "./waniwani.js";\n\n` +
|
|
867
846
|
` const server = new McpServer(\n` +
|
|
@@ -1003,10 +982,6 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
|
|
|
1003
982
|
"src/waniwani.ts",
|
|
1004
983
|
generateServerApp(app, layout, { runtime, styleDomains: templateStyleDomains(template) }),
|
|
1005
984
|
);
|
|
1006
|
-
if (app.docs.length > 0) {
|
|
1007
|
-
emit("src/docs.ts", generateDocs(app, { runtime }));
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
985
|
// `src/views/` is shared: the template's own views sit alongside the app's,
|
|
1011
986
|
// so it cannot be wiped. Only the entries a previous build wrote are
|
|
1012
987
|
// removed, which is what clears a widget the app has since deleted.
|
|
@@ -1046,9 +1021,14 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
|
|
|
1046
1021
|
sha: template.sha,
|
|
1047
1022
|
local: template.local,
|
|
1048
1023
|
manifest: manifest ? MANIFEST_FILE : undefined,
|
|
1049
|
-
// What survived to the end
|
|
1050
|
-
//
|
|
1051
|
-
|
|
1024
|
+
// What survived to the end, copied and generated alike. The
|
|
1025
|
+
// copy is the raw list minus whatever a generated file replaced,
|
|
1026
|
+
// and the generated half is here so that a build which stops
|
|
1027
|
+
// emitting one — `src/docs.ts` when docs left the framework —
|
|
1028
|
+
// cleans up the copy the previous build left behind.
|
|
1029
|
+
files: [...new Set([...fromTemplate, ...GENERATED])].filter((file) =>
|
|
1030
|
+
existsSync(join(root, file)),
|
|
1031
|
+
),
|
|
1052
1032
|
// Tracked separately because `src/views/` is shared with the
|
|
1053
1033
|
// template — the next build needs to know which entries were
|
|
1054
1034
|
// ours before it removes any.
|
package/cli/framework.mjs
CHANGED
|
@@ -91,7 +91,6 @@ function reword(line) {
|
|
|
91
91
|
*
|
|
92
92
|
* The framework's own tunnel is one of those lines. No command here asks for it,
|
|
93
93
|
* so what arrives is its offer of one, and the emoji it carries drops the line.
|
|
94
|
-
* A public hostname comes from `waniwani tunnel` instead (see ./tunnel.mjs).
|
|
95
94
|
*
|
|
96
95
|
* Each pattern leads with `\W*` to absorb whatever emoji prefixes the line and
|
|
97
96
|
* ends at `$`, so a rule reads the framework's whole line and can't fire on an
|
package/cli/index.mjs
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* The `waniwani` CLI.
|
|
4
4
|
*
|
|
5
|
+
* waniwani init scaffold a new app folder and install it
|
|
5
6
|
* waniwani check validate the app folder
|
|
6
7
|
* waniwani dev check, generate, run the dev server, watch for changes
|
|
7
|
-
* waniwani tunnel dev, on a public hostname, wired to the playground
|
|
8
8
|
* waniwani build check, generate, build for production
|
|
9
9
|
* waniwani start run the production build
|
|
10
|
-
* waniwani deploy build, then deploy the generated project to Vercel
|
|
11
10
|
* waniwani eject write the plumbing into the repo and hand it over
|
|
12
11
|
*
|
|
13
12
|
* Every command scans the app folder, validates it, and generates a complete
|
|
@@ -19,20 +18,19 @@ import { spawn } from "node:child_process";
|
|
|
19
18
|
import { existsSync, readFileSync, watch } from "node:fs";
|
|
20
19
|
import { dirname, join, resolve } from "node:path";
|
|
21
20
|
import { fileURLToPath } from "node:url";
|
|
22
|
-
import { connectAccount, createClient } from "./account.mjs";
|
|
23
21
|
import { existingPlumbing, generate } from "./codegen.mjs";
|
|
24
|
-
import {
|
|
22
|
+
import { init } from "./init.mjs";
|
|
23
|
+
import { banner, bold, dim, green, printReport, red, yellow } from "./log.mjs";
|
|
25
24
|
import { scanApp } from "./scan.mjs";
|
|
26
25
|
import { devFilter, FRAMEWORK_ENV, frameworkBin, loadBuildSteps, runBuildSteps, startFilter } from "./framework.mjs";
|
|
27
26
|
import { DEFAULT_TEMPLATE, describeTemplate, resolveTemplate } from "./template.mjs";
|
|
28
|
-
import { findAvailablePort, isPortAvailable, startNamedTunnel, waitForLocalServer } from "./tunnel.mjs";
|
|
29
27
|
import { validateApp } from "./validate.mjs";
|
|
30
28
|
|
|
31
29
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
30
|
const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
|
|
33
31
|
|
|
34
32
|
/** The commands a human sits and watches. `check` and `eject` are often scripted. */
|
|
35
|
-
const BANNERED = new Set(["
|
|
33
|
+
const BANNERED = new Set(["init", "dev", "build", "start"]);
|
|
36
34
|
|
|
37
35
|
/**
|
|
38
36
|
* Diagnostics about this CLI's own machinery — which template was resolved, how
|
|
@@ -67,11 +65,8 @@ function binPath(from) {
|
|
|
67
65
|
*
|
|
68
66
|
* `shell` is for the framework's build steps, which name their command as one
|
|
69
67
|
* string rather than an argv.
|
|
70
|
-
*
|
|
71
|
-
* `onChild` hands the process back to the caller. `tunnel` keeps working while
|
|
72
|
-
* the dev server runs and has to be able to take it down with it.
|
|
73
68
|
*/
|
|
74
|
-
function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilter
|
|
69
|
+
function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilter } = {}) {
|
|
75
70
|
return new Promise((resolvePromise) => {
|
|
76
71
|
const child = spawn(command, args, {
|
|
77
72
|
cwd,
|
|
@@ -79,7 +74,6 @@ function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilte
|
|
|
79
74
|
stdio: ["inherit", stdoutFilter ? "pipe" : "inherit", stderrFilter ? "pipe" : "inherit"],
|
|
80
75
|
env: { ...process.env, PATH: binPath(cwd), ...FRAMEWORK_ENV, ...env },
|
|
81
76
|
});
|
|
82
|
-
onChild?.(child);
|
|
83
77
|
for (const [stream, filter] of [
|
|
84
78
|
[child.stdout, stdoutFilter],
|
|
85
79
|
[child.stderr, stderrFilter],
|
|
@@ -239,9 +233,6 @@ async function eject(appRoot, flags) {
|
|
|
239
233
|
}
|
|
240
234
|
console.log(` ${dim("·")} the runtime is now yours, vendored as source in ${bold("src/_runtime/")}`);
|
|
241
235
|
console.log(` ${dim("·")} @waniwani/kit imports point at src/_runtime/ — drop the dependency`);
|
|
242
|
-
console.log(
|
|
243
|
-
` ${dim("·")} docs/*.md are inlined into ${bold("src/docs.ts")} — regenerate by hand from here on`,
|
|
244
|
-
);
|
|
245
236
|
console.log(
|
|
246
237
|
` ${dim("·")} widgets/<name>/ no longer becomes a view — add ${bold("src/views/<name>.tsx")} by hand`,
|
|
247
238
|
);
|
|
@@ -277,7 +268,7 @@ function watchApp(appRoot, template) {
|
|
|
277
268
|
}, 120);
|
|
278
269
|
};
|
|
279
270
|
|
|
280
|
-
for (const dir of ["tools", "widgets", "flows"
|
|
271
|
+
for (const dir of ["tools", "widgets", "flows"]) {
|
|
281
272
|
const path = join(appRoot, dir);
|
|
282
273
|
if (existsSync(path)) {
|
|
283
274
|
watch(path, { recursive: true }, rebuild);
|
|
@@ -297,176 +288,23 @@ function watchApp(appRoot, template) {
|
|
|
297
288
|
* the framework's auto-open of its own DevTools page in the browser; the URL is
|
|
298
289
|
* printed instead.
|
|
299
290
|
*/
|
|
300
|
-
function devServer(outDir
|
|
291
|
+
function devServer(outDir) {
|
|
301
292
|
return run("node", [frameworkBin(), "dev", "--plain"], {
|
|
302
293
|
cwd: outDir,
|
|
303
|
-
env,
|
|
304
294
|
stderrFilter: devFilter(),
|
|
305
|
-
onChild,
|
|
306
295
|
});
|
|
307
296
|
}
|
|
308
297
|
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
const DEFAULT_DEV_PORT = 3000;
|
|
312
|
-
|
|
313
|
-
function parsePort(raw) {
|
|
314
|
-
const port = typeof raw === "string" ? Number(raw) : Number.NaN;
|
|
315
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
316
|
-
throw new Error("--port wants an integer between 1 and 65535");
|
|
317
|
-
}
|
|
318
|
-
return port;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* The port the dev server takes, which is also the port the tunnel's ingress is
|
|
323
|
-
* pointed at.
|
|
324
|
-
*
|
|
325
|
-
* An explicit `--port` is taken at its word and fails when it is busy, since the
|
|
326
|
-
* caller asked for that one. Otherwise the first free port from the configured
|
|
327
|
-
* default is used: the alternative is a dev server that quietly moves to 3001
|
|
328
|
-
* while the tunnel forwards to 3000.
|
|
329
|
-
*/
|
|
330
|
-
async function resolveDevPort(flags, configured) {
|
|
331
|
-
// Presence, not truthiness: a bare `--port` with nothing after it parses as an
|
|
332
|
-
// undefined value, and picking a port anyway would ignore what was asked for.
|
|
333
|
-
if ("port" in flags) {
|
|
334
|
-
const port = parsePort(flags.port);
|
|
335
|
-
if (!(await isPortAvailable(port))) {
|
|
336
|
-
throw new Error(`port ${port} is in use: free it or pass a different --port`);
|
|
337
|
-
}
|
|
338
|
-
return port;
|
|
339
|
-
}
|
|
340
|
-
const start = configured ?? DEFAULT_DEV_PORT;
|
|
341
|
-
const port = await findAvailablePort(start);
|
|
342
|
-
if (port !== start) {
|
|
343
|
-
console.log(dim(`[waniwani] port ${start} is in use, using ${port}`));
|
|
344
|
-
}
|
|
345
|
-
return port;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** `--open`. A dev loop prints its URLs and leaves the browser to the developer. */
|
|
349
|
-
function openBrowser(url) {
|
|
350
|
-
const [command, args] =
|
|
351
|
-
process.platform === "darwin"
|
|
352
|
-
? ["open", [url]]
|
|
353
|
-
: process.platform === "win32"
|
|
354
|
-
? ["cmd", ["/c", "start", "", url]]
|
|
355
|
-
: ["xdg-open", [url]];
|
|
356
|
-
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
357
|
-
}
|
|
298
|
+
/** Flags that take a value; everything else is a boolean switch. */
|
|
299
|
+
const VALUE_FLAGS = new Set(["out", "template", "name"]);
|
|
358
300
|
|
|
359
301
|
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* Everything `dev` does happens here too, on a port this command picks. What it
|
|
363
|
-
* adds is the round trip to app.waniwani.ai: a connector token for the agent's
|
|
364
|
-
* `<slug>.waniwani.dev` hostname, cloudflared running against it, and a dev
|
|
365
|
-
* session held open by a heartbeat. The session is what points the playground at
|
|
366
|
-
* this machine while the command runs, and at the deployed agent once it stops.
|
|
302
|
+
* `--out dir` / `--template=github:o/r#ref` alongside a positional app directory.
|
|
367
303
|
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
304
|
+
* `--no-install` sets `install` to false, so a switch that is on by default is
|
|
305
|
+
* read as one flag with two states instead of two flags a caller can set to
|
|
306
|
+
* contradict each other.
|
|
371
307
|
*/
|
|
372
|
-
async function tunnel(appRoot, flags) {
|
|
373
|
-
const account = await connectAccount(appRoot);
|
|
374
|
-
const prepared = await prepare(appRoot, flags);
|
|
375
|
-
if (!prepared) return 1;
|
|
376
|
-
|
|
377
|
-
const port = await resolveDevPort(flags, account.devPort);
|
|
378
|
-
const client = createClient(account.apiUrl);
|
|
379
|
-
const sessions = `/api/mcp/projects/${account.projectId}/dev-session`;
|
|
380
|
-
|
|
381
|
-
let child = null;
|
|
382
|
-
let session = null;
|
|
383
|
-
let open = null;
|
|
384
|
-
let heartbeat = null;
|
|
385
|
-
let closing = false;
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Take down the session, the tunnel and the dev server, in that order.
|
|
389
|
-
*
|
|
390
|
-
* The session goes first and on a timeout: one left behind keeps the
|
|
391
|
-
* playground calling a hostname that has stopped answering until the
|
|
392
|
-
* heartbeat ages out server-side, and a slow API call is not a reason to
|
|
393
|
-
* hold the terminal.
|
|
394
|
-
*/
|
|
395
|
-
const shutdown = async (code) => {
|
|
396
|
-
if (closing) return code;
|
|
397
|
-
closing = true;
|
|
398
|
-
clearInterval(heartbeat);
|
|
399
|
-
if (session) {
|
|
400
|
-
await Promise.race([
|
|
401
|
-
client.delete(`${sessions}/${session}`).catch(() => {}),
|
|
402
|
-
new Promise((resolveTimeout) => setTimeout(resolveTimeout, SESSION_DELETE_TIMEOUT_MS)),
|
|
403
|
-
]);
|
|
404
|
-
}
|
|
405
|
-
open?.stop();
|
|
406
|
-
if (child?.exitCode === null) child.kill("SIGTERM");
|
|
407
|
-
return code;
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
411
|
-
process.once(signal, () => {
|
|
412
|
-
void shutdown(0).then((code) => process.exit(code));
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
watchApp(appRoot, prepared.template);
|
|
417
|
-
const devLoop = devServer(prepared.outDir, {
|
|
418
|
-
env: { PORT: String(port) },
|
|
419
|
-
onChild: (spawned) => {
|
|
420
|
-
child = spawned;
|
|
421
|
-
},
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// A dev server that dies on startup, from a port taken in the meantime or a
|
|
425
|
-
// broken vite config, would otherwise sit out the readiness timeout.
|
|
426
|
-
const earlyExit = devLoop.then((code) =>
|
|
427
|
-
Promise.reject(new Error(`the dev server exited with code ${code} before it was ready`)),
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
try {
|
|
431
|
-
console.log(dim(`[waniwani] waiting for the dev server on port ${port}…`));
|
|
432
|
-
try {
|
|
433
|
-
await Promise.race([waitForLocalServer(`http://localhost:${port}/`), earlyExit]);
|
|
434
|
-
} finally {
|
|
435
|
-
// The race is settled either way, so the loser's rejection needs an owner.
|
|
436
|
-
earlyExit.catch(() => {});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
console.log(dim("[waniwani] opening the tunnel…"));
|
|
440
|
-
open = await startNamedTunnel(await client.post(`/api/mcp/projects/${account.projectId}/tunnel`, { port }));
|
|
441
|
-
|
|
442
|
-
// Creating the session takes no payload: the hostname the playground
|
|
443
|
-
// routes to is the tunnel's, and the API already holds it.
|
|
444
|
-
session = (await client.post(sessions, {})).id;
|
|
445
|
-
heartbeat = setInterval(() => {
|
|
446
|
-
// Silent on failure. A beat that does not land costs the session, and
|
|
447
|
-
// the playground falls back to the deployed agent.
|
|
448
|
-
void client.patch(`${sessions}/${session}`).catch(() => {});
|
|
449
|
-
}, HEARTBEAT_MS);
|
|
450
|
-
|
|
451
|
-
console.log("");
|
|
452
|
-
console.log(endpoint("public", `${open.publicUrl}/mcp`));
|
|
453
|
-
console.log(endpoint("try", account.playgroundUrl));
|
|
454
|
-
console.log("");
|
|
455
|
-
if (flags.open) {
|
|
456
|
-
openBrowser(account.playgroundUrl);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return await shutdown(await devLoop);
|
|
460
|
-
} catch (error) {
|
|
461
|
-
await shutdown(1);
|
|
462
|
-
throw error;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Flags that take a value; everything else is a boolean switch. */
|
|
467
|
-
const VALUE_FLAGS = new Set(["out", "template", "port"]);
|
|
468
|
-
|
|
469
|
-
/** `--out dir` / `--template=github:o/r#ref` alongside a positional app directory. */
|
|
470
308
|
function parseArgs(argv) {
|
|
471
309
|
const flags = {};
|
|
472
310
|
const positional = [];
|
|
@@ -477,6 +315,10 @@ function parseArgs(argv) {
|
|
|
477
315
|
continue;
|
|
478
316
|
}
|
|
479
317
|
const [name, inline] = arg.slice(2).split("=");
|
|
318
|
+
if (name.startsWith("no-")) {
|
|
319
|
+
flags[name.slice(3)] = false;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
480
322
|
flags[name] = VALUE_FLAGS.has(name) ? (inline ?? argv[++i]) : true;
|
|
481
323
|
}
|
|
482
324
|
return { flags, positional };
|
|
@@ -491,6 +333,12 @@ async function main() {
|
|
|
491
333
|
banner(PACKAGE_VERSION);
|
|
492
334
|
}
|
|
493
335
|
|
|
336
|
+
if (command === "init") {
|
|
337
|
+
// Whether a directory was named matters only here: with none, the answer to
|
|
338
|
+
// the one question decides where the app goes.
|
|
339
|
+
process.exit(await init(appRoot, flags, { targeted: positional.length > 0 }));
|
|
340
|
+
}
|
|
341
|
+
|
|
494
342
|
if (command === "check") {
|
|
495
343
|
const app = scanApp(appRoot);
|
|
496
344
|
const report = await validateApp(app);
|
|
@@ -505,10 +353,6 @@ async function main() {
|
|
|
505
353
|
process.exit(await devServer(prepared.outDir));
|
|
506
354
|
}
|
|
507
355
|
|
|
508
|
-
if (command === "tunnel") {
|
|
509
|
-
process.exit(await tunnel(appRoot, flags));
|
|
510
|
-
}
|
|
511
|
-
|
|
512
356
|
if (command === "build") {
|
|
513
357
|
const prepared = await prepare(appRoot, flags);
|
|
514
358
|
if (!prepared) process.exit(1);
|
|
@@ -529,22 +373,12 @@ async function main() {
|
|
|
529
373
|
);
|
|
530
374
|
}
|
|
531
375
|
|
|
532
|
-
if (command === "deploy") {
|
|
533
|
-
const prepared = await prepare(appRoot, flags);
|
|
534
|
-
if (!prepared) process.exit(1);
|
|
535
|
-
console.log(dim("[waniwani] deploying the generated project to Vercel…"));
|
|
536
|
-
const code = await run("vercel", ["deploy", ...(flags.prod ? ["--prod"] : [])], {
|
|
537
|
-
cwd: prepared.outDir,
|
|
538
|
-
});
|
|
539
|
-
process.exit(code);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
376
|
if (command === "eject") {
|
|
543
377
|
process.exit(await eject(appRoot, flags));
|
|
544
378
|
}
|
|
545
379
|
|
|
546
380
|
console.error(red(`unknown command: ${command}`));
|
|
547
|
-
console.error(dim("usage: waniwani <check|dev|
|
|
381
|
+
console.error(dim("usage: waniwani <init|check|dev|build|start|eject> [dir]"));
|
|
548
382
|
process.exit(1);
|
|
549
383
|
}
|
|
550
384
|
|