@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/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 { banner, bold, dim, endpoint, green, printReport, red, yellow } from "./log.mjs";
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(["dev", "tunnel", "build", "start", "deploy"]);
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, onChild } = {}) {
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", "docs"]) {
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, { env, onChild } = {}) {
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
- 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
- }
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
- * 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.
303
+ * `--out dir` / `--template=github:o/r#ref` alongside a positional app directory.
367
304
  *
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.
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|tunnel|build|start|deploy|eject> [dir]"));
388
+ console.error(dim("usage: waniwani <init|check|dev|build|start|eject> [dir]"));
548
389
  process.exit(1);
549
390
  }
550
391