@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/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,20 @@ 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 { loadAppEnv } from "./env.mjs";
|
|
23
|
+
import { init } from "./init.mjs";
|
|
24
|
+
import { banner, bold, dim, green, printReport, red, yellow } from "./log.mjs";
|
|
25
25
|
import { scanApp } from "./scan.mjs";
|
|
26
26
|
import { devFilter, FRAMEWORK_ENV, frameworkBin, loadBuildSteps, runBuildSteps, startFilter } from "./framework.mjs";
|
|
27
27
|
import { DEFAULT_TEMPLATE, describeTemplate, resolveTemplate } from "./template.mjs";
|
|
28
|
-
import { findAvailablePort, isPortAvailable, startNamedTunnel, waitForLocalServer } from "./tunnel.mjs";
|
|
29
28
|
import { validateApp } from "./validate.mjs";
|
|
30
29
|
|
|
31
30
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
31
|
const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
|
|
33
32
|
|
|
34
33
|
/** The commands a human sits and watches. `check` and `eject` are often scripted. */
|
|
35
|
-
const BANNERED = new Set(["
|
|
34
|
+
const BANNERED = new Set(["init", "dev", "build", "start"]);
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
37
|
* Diagnostics about this CLI's own machinery — which template was resolved, how
|
|
@@ -67,11 +66,8 @@ function binPath(from) {
|
|
|
67
66
|
*
|
|
68
67
|
* `shell` is for the framework's build steps, which name their command as one
|
|
69
68
|
* 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
69
|
*/
|
|
74
|
-
function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilter
|
|
70
|
+
function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilter } = {}) {
|
|
75
71
|
return new Promise((resolvePromise) => {
|
|
76
72
|
const child = spawn(command, args, {
|
|
77
73
|
cwd,
|
|
@@ -79,7 +75,6 @@ function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilte
|
|
|
79
75
|
stdio: ["inherit", stdoutFilter ? "pipe" : "inherit", stderrFilter ? "pipe" : "inherit"],
|
|
80
76
|
env: { ...process.env, PATH: binPath(cwd), ...FRAMEWORK_ENV, ...env },
|
|
81
77
|
});
|
|
82
|
-
onChild?.(child);
|
|
83
78
|
for (const [stream, filter] of [
|
|
84
79
|
[child.stdout, stdoutFilter],
|
|
85
80
|
[child.stderr, stderrFilter],
|
|
@@ -239,9 +234,6 @@ async function eject(appRoot, flags) {
|
|
|
239
234
|
}
|
|
240
235
|
console.log(` ${dim("·")} the runtime is now yours, vendored as source in ${bold("src/_runtime/")}`);
|
|
241
236
|
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
237
|
console.log(
|
|
246
238
|
` ${dim("·")} widgets/<name>/ no longer becomes a view — add ${bold("src/views/<name>.tsx")} by hand`,
|
|
247
239
|
);
|
|
@@ -277,7 +269,7 @@ function watchApp(appRoot, template) {
|
|
|
277
269
|
}, 120);
|
|
278
270
|
};
|
|
279
271
|
|
|
280
|
-
for (const dir of ["tools", "widgets", "flows", "
|
|
272
|
+
for (const dir of ["tools", "widgets", "flows", "api"]) {
|
|
281
273
|
const path = join(appRoot, dir);
|
|
282
274
|
if (existsSync(path)) {
|
|
283
275
|
watch(path, { recursive: true }, rebuild);
|
|
@@ -297,176 +289,23 @@ function watchApp(appRoot, template) {
|
|
|
297
289
|
* the framework's auto-open of its own DevTools page in the browser; the URL is
|
|
298
290
|
* printed instead.
|
|
299
291
|
*/
|
|
300
|
-
function devServer(outDir
|
|
292
|
+
function devServer(outDir) {
|
|
301
293
|
return run("node", [frameworkBin(), "dev", "--plain"], {
|
|
302
294
|
cwd: outDir,
|
|
303
|
-
env,
|
|
304
295
|
stderrFilter: devFilter(),
|
|
305
|
-
onChild,
|
|
306
296
|
});
|
|
307
297
|
}
|
|
308
298
|
|
|
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
|
-
}
|
|
299
|
+
/** Flags that take a value; everything else is a boolean switch. */
|
|
300
|
+
const VALUE_FLAGS = new Set(["out", "template", "name"]);
|
|
358
301
|
|
|
359
302
|
/**
|
|
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.
|
|
303
|
+
* `--out dir` / `--template=github:o/r#ref` alongside a positional app directory.
|
|
367
304
|
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
305
|
+
* `--no-install` sets `install` to false, so a switch that is on by default is
|
|
306
|
+
* read as one flag with two states instead of two flags a caller can set to
|
|
307
|
+
* contradict each other.
|
|
371
308
|
*/
|
|
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
309
|
function parseArgs(argv) {
|
|
471
310
|
const flags = {};
|
|
472
311
|
const positional = [];
|
|
@@ -477,6 +316,10 @@ function parseArgs(argv) {
|
|
|
477
316
|
continue;
|
|
478
317
|
}
|
|
479
318
|
const [name, inline] = arg.slice(2).split("=");
|
|
319
|
+
if (name.startsWith("no-")) {
|
|
320
|
+
flags[name.slice(3)] = false;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
480
323
|
flags[name] = VALUE_FLAGS.has(name) ? (inline ?? argv[++i]) : true;
|
|
481
324
|
}
|
|
482
325
|
return { flags, positional };
|
|
@@ -491,6 +334,18 @@ async function main() {
|
|
|
491
334
|
banner(PACKAGE_VERSION);
|
|
492
335
|
}
|
|
493
336
|
|
|
337
|
+
// Before anything is spawned, so every child inherits the app's variables
|
|
338
|
+
// whatever order its modules evaluate in. `init` has no app to read yet.
|
|
339
|
+
if (command !== "init") {
|
|
340
|
+
loadAppEnv(appRoot);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (command === "init") {
|
|
344
|
+
// Whether a directory was named matters only here: with none, the answer to
|
|
345
|
+
// the one question decides where the app goes.
|
|
346
|
+
process.exit(await init(appRoot, flags, { targeted: positional.length > 0 }));
|
|
347
|
+
}
|
|
348
|
+
|
|
494
349
|
if (command === "check") {
|
|
495
350
|
const app = scanApp(appRoot);
|
|
496
351
|
const report = await validateApp(app);
|
|
@@ -505,10 +360,6 @@ async function main() {
|
|
|
505
360
|
process.exit(await devServer(prepared.outDir));
|
|
506
361
|
}
|
|
507
362
|
|
|
508
|
-
if (command === "tunnel") {
|
|
509
|
-
process.exit(await tunnel(appRoot, flags));
|
|
510
|
-
}
|
|
511
|
-
|
|
512
363
|
if (command === "build") {
|
|
513
364
|
const prepared = await prepare(appRoot, flags);
|
|
514
365
|
if (!prepared) process.exit(1);
|
|
@@ -529,22 +380,12 @@ async function main() {
|
|
|
529
380
|
);
|
|
530
381
|
}
|
|
531
382
|
|
|
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
383
|
if (command === "eject") {
|
|
543
384
|
process.exit(await eject(appRoot, flags));
|
|
544
385
|
}
|
|
545
386
|
|
|
546
387
|
console.error(red(`unknown command: ${command}`));
|
|
547
|
-
console.error(dim("usage: waniwani <check|dev|
|
|
388
|
+
console.error(dim("usage: waniwani <init|check|dev|build|start|eject> [dir]"));
|
|
548
389
|
process.exit(1);
|
|
549
390
|
}
|
|
550
391
|
|