@waniwani/kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/index.mjs ADDED
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `waniwani` CLI.
4
+ *
5
+ * waniwani check validate the app folder
6
+ * waniwani dev check, generate, run the dev server, watch for changes
7
+ * waniwani tunnel dev, on a public hostname, wired to the playground
8
+ * waniwani build check, generate, build for production
9
+ * waniwani start run the production build
10
+ * waniwani deploy build, then deploy the generated project to Vercel
11
+ * waniwani eject write the plumbing into the repo and hand it over
12
+ *
13
+ * Every command scans the app folder, validates it, and generates a complete
14
+ * framework project under `.waniwani/`. The app repo owns content; this CLI and
15
+ * the runtime own everything else.
16
+ */
17
+
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync, readFileSync, watch } from "node:fs";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { connectAccount, createClient } from "./account.mjs";
23
+ import { existingPlumbing, generate } from "./codegen.mjs";
24
+ import { banner, bold, dim, endpoint, green, printReport, red, yellow } from "./log.mjs";
25
+ import { scanApp } from "./scan.mjs";
26
+ import { devFilter, FRAMEWORK_ENV, frameworkBin, loadBuildSteps, runBuildSteps, startFilter } from "./framework.mjs";
27
+ import { DEFAULT_TEMPLATE, describeTemplate, resolveTemplate } from "./template.mjs";
28
+ import { findAvailablePort, isPortAvailable, startNamedTunnel, waitForLocalServer } from "./tunnel.mjs";
29
+ import { validateApp } from "./validate.mjs";
30
+
31
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
32
+ const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
33
+
34
+ /** The commands a human sits and watches. `check` and `eject` are often scripted. */
35
+ const BANNERED = new Set(["dev", "tunnel", "build", "start", "deploy"]);
36
+
37
+ /**
38
+ * Diagnostics about this CLI's own machinery — which template was resolved, how
39
+ * many files it copied, which dependencies were overridden, stack traces. None
40
+ * of it is actionable from an app folder, so it is off unless asked for.
41
+ */
42
+ const DEBUG = Boolean(process.env.WANIWANI_DEBUG);
43
+
44
+ /**
45
+ * Every `node_modules/.bin` from the generated project up to the filesystem
46
+ * root. The framework shells out to `vite` and `tsc` by bare name.
47
+ */
48
+ function binPath(from) {
49
+ const dirs = [];
50
+ let current = resolve(from);
51
+ while (true) {
52
+ dirs.push(join(current, "node_modules", ".bin"));
53
+ const parent = dirname(current);
54
+ if (parent === current) break;
55
+ current = parent;
56
+ }
57
+ return [...dirs, process.env.PATH].join(":");
58
+ }
59
+
60
+ /**
61
+ * Spawn a child under this CLI's PATH and environment.
62
+ *
63
+ * `stdoutFilter`/`stderrFilter` pipe that one stream through a line rewriter
64
+ * instead of inheriting it — the mechanism that keeps the framework's own
65
+ * narration out of this CLI's output (see ./framework.mjs). Every unfiltered
66
+ * stream is inherited, so an app's logs and tsc's diagnostics arrive untouched.
67
+ *
68
+ * `shell` is for the framework's build steps, which name their command as one
69
+ * 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
+ */
74
+ function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilter, onChild } = {}) {
75
+ return new Promise((resolvePromise) => {
76
+ const child = spawn(command, args, {
77
+ cwd,
78
+ shell,
79
+ stdio: ["inherit", stdoutFilter ? "pipe" : "inherit", stderrFilter ? "pipe" : "inherit"],
80
+ env: { ...process.env, PATH: binPath(cwd), ...FRAMEWORK_ENV, ...env },
81
+ });
82
+ onChild?.(child);
83
+ for (const [stream, filter] of [
84
+ [child.stdout, stdoutFilter],
85
+ [child.stderr, stderrFilter],
86
+ ]) {
87
+ if (!filter) continue;
88
+ stream.setEncoding("utf8");
89
+ stream.on("data", filter.write);
90
+ // Registered before the resolving listener, so a held partial line is
91
+ // emitted before the command reports its exit code.
92
+ child.on("close", filter.flush);
93
+ }
94
+ child.on("close", (code) => resolvePromise(code ?? 1));
95
+ child.on("error", (error) => {
96
+ console.error(red(`failed to run ${command}: ${error.message}`));
97
+ resolvePromise(1);
98
+ });
99
+ });
100
+ }
101
+
102
+ /** The template source, most specific wins. */
103
+ function templateSource(flags) {
104
+ return flags.template ?? process.env.WANIWANI_TEMPLATE ?? DEFAULT_TEMPLATE;
105
+ }
106
+
107
+ /**
108
+ * Report what the runtime changed about the template's package.json. This is
109
+ * the fleet-wide fix mechanism made visible: every line is a decision taken
110
+ * once here instead of in 30 repos.
111
+ *
112
+ * Every line is about the plumbing rather than the app, so `dev` and `build`
113
+ * print it only under WANIWANI_DEBUG. `eject` prints it unconditionally —
114
+ * there the plumbing becomes the app's to maintain.
115
+ */
116
+ function printOverrides(overrides) {
117
+ if (overrides.length === 0) return;
118
+ console.log(`\n${dim("runtime overrides on top of the template")}`);
119
+ for (const { name, from, to, why, conflict, removed } of overrides) {
120
+ const marker = conflict ? yellow("!") : dim("·");
121
+ const change = removed ? "removed" : from ? `${from} → ${to}` : `+ ${to}`;
122
+ console.log(` ${marker} ${name} ${dim(change)}`);
123
+ console.log(` ${dim(why)}`);
124
+ }
125
+ }
126
+
127
+ async function prepare(appRoot, flags, { quiet = false } = {}) {
128
+ const app = scanApp(appRoot);
129
+ const report = await validateApp(app);
130
+
131
+ if (!quiet) {
132
+ printReport(app, report);
133
+ }
134
+ if (!report.ok) {
135
+ return null;
136
+ }
137
+
138
+ // Where the plumbing came from, how much of it there was, and which
139
+ // dependencies the runtime overrode are all facts about our own machinery.
140
+ // An app author can act on none of them, so they are diagnostics: on under
141
+ // WANIWANI_DEBUG, off otherwise. A stale or unreachable template is different
142
+ // — that one changes what they are running, so it always shows.
143
+ const template = await resolveTemplate(templateSource(flags));
144
+ if (!quiet && DEBUG) {
145
+ console.log(`\n${dim("template")} ${describeTemplate(template)}`);
146
+ }
147
+ if (!quiet && template.offline) {
148
+ console.log(`${yellow("!")} ${dim("GitHub unreachable — using the cached template")}`);
149
+ }
150
+
151
+ const { outDir, overrides, fromTemplate, manifest } = generate(app, { template });
152
+ if (!quiet && DEBUG) {
153
+ console.log(
154
+ `${dim(`${fromTemplate.length} files copied`)} ${dim(
155
+ manifest ? "· exclusions from the template's manifest" : "· exclusions from the built-in defaults",
156
+ )}`,
157
+ );
158
+ printOverrides(overrides);
159
+ }
160
+ return { app, outDir, template };
161
+ }
162
+
163
+ /**
164
+ * Build the generated project for production: compile the server, bundle the
165
+ * views, and emit a Vercel Build Output tree under `.vercel/output/` — no
166
+ * adapter, no vercel.json, nothing for this CLI to stage afterwards.
167
+ *
168
+ * The framework's own `build` command renders exactly these steps inside a
169
+ * branded UI, so the steps are driven here and reported in this CLI's format.
170
+ * When the step list can't be loaded, shelling out is the fallback: the build
171
+ * still runs, it just narrates itself.
172
+ */
173
+ async function build(outDir) {
174
+ const steps = await loadBuildSteps(outDir);
175
+ if (!steps) {
176
+ return run("node", [frameworkBin(), "build"], { cwd: outDir });
177
+ }
178
+
179
+ console.log(`\n${dim("building for production")}`);
180
+ return runBuildSteps(steps, {
181
+ root: outDir,
182
+ // The steps name their command as one string (`tsc -b --force`), and reach
183
+ // for `tsc` and `vite` by bare name.
184
+ runShell: (command) => run(command, [], { cwd: outDir, shell: true }),
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Write the plumbing into the app repo and step out of the way. What comes out
190
+ * is an ordinary project on the underlying framework, driven by its own CLI: a
191
+ * Dockerfile, a vercel.json, and the runtime vendored as readable source. No
192
+ * dependency on this CLI or on Waniwani remains.
193
+ */
194
+ async function eject(appRoot, flags) {
195
+ const app = scanApp(appRoot);
196
+ const report = await validateApp(app);
197
+ printReport(app, report);
198
+ if (!report.ok) return 1;
199
+
200
+ const outDir = flags.out ? resolve(flags.out) : appRoot;
201
+ const inPlace = outDir === appRoot;
202
+
203
+ // Which files count as plumbing depends on what the template ships, so the
204
+ // template has to be resolved before the question can be asked.
205
+ const template = await resolveTemplate(templateSource(flags));
206
+ console.log(`\n${dim("template")} ${describeTemplate(template)}`);
207
+
208
+ const clashes = existingPlumbing(outDir, template);
209
+ if (clashes.length > 0 && !flags.force) {
210
+ console.error(`\n${red("✗")} ${bold("would overwrite existing files")}\n`);
211
+ for (const file of clashes) {
212
+ console.error(` ${file}`);
213
+ }
214
+ console.error(`\n${dim("pass --force to overwrite, or --out <dir> to write elsewhere")}`);
215
+ return 1;
216
+ }
217
+
218
+ const { written, overrides, fromTemplate, moved } = generate(app, {
219
+ template,
220
+ layout: "eject",
221
+ outDir,
222
+ });
223
+
224
+ console.log(`\n${green("✓")} ${bold("Ejected")} ${dim(`→ ${outDir}`)}\n`);
225
+ for (const file of written) {
226
+ console.log(` ${file}`);
227
+ }
228
+ console.log(` ${dim(`+ ${fromTemplate.length} files from the template`)}`);
229
+ printOverrides(overrides);
230
+
231
+ console.log(`\n${bold("What changed")}`);
232
+ if (moved.length > 0) {
233
+ console.log(
234
+ ` ${dim("·")} your source ${bold("moved")} under ${bold("src/app/")}: ${moved.join(", ")}`,
235
+ );
236
+ console.log(
237
+ ` ${dim("the framework compiles from src/ — nothing outside it can be an input")}`,
238
+ );
239
+ }
240
+ console.log(` ${dim("·")} the runtime is now yours, vendored as source in ${bold("src/_runtime/")}`);
241
+ 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
+ console.log(
246
+ ` ${dim("·")} widgets/<name>/ no longer becomes a view — add ${bold("src/views/<name>.tsx")} by hand`,
247
+ );
248
+ if (inPlace) {
249
+ console.log(` ${dim("·")} ${bold(".waniwani/")} is dead weight — delete it`);
250
+ }
251
+
252
+ console.log(`\n${bold("From here")}`);
253
+ console.log(` npm install && npm run dev`);
254
+ console.log(`\n${yellow("!")} ${dim("one way — nothing turns an ejected repo back")}`);
255
+ return 0;
256
+ }
257
+
258
+ /**
259
+ * Mirror app-folder edits into the build output; nodemon and Vite do the rest.
260
+ * The template is resolved once at startup and reused, so a dev loop never
261
+ * touches the network.
262
+ */
263
+ function watchApp(appRoot, template) {
264
+ let pending = null;
265
+ const rebuild = () => {
266
+ clearTimeout(pending);
267
+ pending = setTimeout(async () => {
268
+ const app = scanApp(appRoot);
269
+ const report = await validateApp(app);
270
+ if (!report.ok) {
271
+ printReport(app, report);
272
+ return;
273
+ }
274
+ // Rewriting the output restarts nodemon and triggers Vite HMR.
275
+ generate(app, { template });
276
+ console.log(dim(`[waniwani] regenerated ${new Date().toLocaleTimeString()}`));
277
+ }, 120);
278
+ };
279
+
280
+ for (const dir of ["tools", "widgets", "flows", "docs"]) {
281
+ const path = join(appRoot, dir);
282
+ if (existsSync(path)) {
283
+ watch(path, { recursive: true }, rebuild);
284
+ }
285
+ }
286
+ const config = join(appRoot, "waniwani.config.ts");
287
+ if (existsSync(config)) {
288
+ watch(config, rebuild);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * The framework's dev server, under this CLI's narration.
294
+ *
295
+ * `--plain` trades the framework's interactive UI for one plain line per
296
+ * diagnostic on stderr, which is what makes the output rewritable. It also drops
297
+ * the framework's auto-open of its own DevTools page in the browser; the URL is
298
+ * printed instead.
299
+ */
300
+ function devServer(outDir, { env, onChild } = {}) {
301
+ return run("node", [frameworkBin(), "dev", "--plain"], {
302
+ cwd: outDir,
303
+ env,
304
+ stderrFilter: devFilter(),
305
+ onChild,
306
+ });
307
+ }
308
+
309
+ const HEARTBEAT_MS = 30_000;
310
+ const SESSION_DELETE_TIMEOUT_MS = 2_000;
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
+ }
358
+
359
+ /**
360
+ * The dev loop, reachable from the internet and wired to the agent's playground.
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.
367
+ *
368
+ * Which account and which agent come from the two files `@waniwani/cli` and the
369
+ * SDK already share (see ./account.mjs), and a missing one sends the developer
370
+ * through that CLI's login or connect flow on the way in.
371
+ */
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
+ function parseArgs(argv) {
471
+ const flags = {};
472
+ const positional = [];
473
+ for (let i = 0; i < argv.length; i++) {
474
+ const arg = argv[i];
475
+ if (!arg.startsWith("--")) {
476
+ positional.push(arg);
477
+ continue;
478
+ }
479
+ const [name, inline] = arg.slice(2).split("=");
480
+ flags[name] = VALUE_FLAGS.has(name) ? (inline ?? argv[++i]) : true;
481
+ }
482
+ return { flags, positional };
483
+ }
484
+
485
+ async function main() {
486
+ const [command = "dev", ...rest] = process.argv.slice(2);
487
+ const { flags, positional } = parseArgs(rest);
488
+ const appRoot = resolve(positional[0] ?? process.cwd());
489
+
490
+ if (BANNERED.has(command)) {
491
+ banner(PACKAGE_VERSION);
492
+ }
493
+
494
+ if (command === "check") {
495
+ const app = scanApp(appRoot);
496
+ const report = await validateApp(app);
497
+ printReport(app, report);
498
+ process.exit(report.ok ? 0 : 1);
499
+ }
500
+
501
+ if (command === "dev") {
502
+ const prepared = await prepare(appRoot, flags);
503
+ if (!prepared) process.exit(1);
504
+ watchApp(appRoot, prepared.template);
505
+ process.exit(await devServer(prepared.outDir));
506
+ }
507
+
508
+ if (command === "tunnel") {
509
+ process.exit(await tunnel(appRoot, flags));
510
+ }
511
+
512
+ if (command === "build") {
513
+ const prepared = await prepare(appRoot, flags);
514
+ if (!prepared) process.exit(1);
515
+ const code = await build(prepared.outDir);
516
+ if (code !== 0) process.exit(code);
517
+ console.log(`\n${green("✓")} built ${bold(prepared.outDir)}`);
518
+ process.exit(0);
519
+ }
520
+
521
+ if (command === "start") {
522
+ const outDir = join(appRoot, ".waniwani");
523
+ if (!existsSync(join(outDir, "dist"))) {
524
+ console.error(red("no build output — run `waniwani build` first"));
525
+ process.exit(1);
526
+ }
527
+ process.exit(
528
+ await run("node", [frameworkBin(), "start"], { cwd: outDir, stdoutFilter: startFilter() }),
529
+ );
530
+ }
531
+
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
+ if (command === "eject") {
543
+ process.exit(await eject(appRoot, flags));
544
+ }
545
+
546
+ console.error(red(`unknown command: ${command}`));
547
+ console.error(dim("usage: waniwani <check|dev|tunnel|build|start|deploy|eject> [dir]"));
548
+ process.exit(1);
549
+ }
550
+
551
+ try {
552
+ await main();
553
+ } catch (error) {
554
+ // Template resolution and generation failures are expected conditions —
555
+ // a bad ref, an unreachable network, a template whose shape moved.
556
+ console.error(`\n${red("✗")} ${error instanceof Error ? error.message : String(error)}`);
557
+ if (DEBUG) {
558
+ console.error(error);
559
+ } else {
560
+ console.error(dim("set WANIWANI_DEBUG=1 for the stack trace"));
561
+ }
562
+ process.exit(1);
563
+ }