@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/LICENSE +21 -0
- package/README.md +566 -0
- package/cli/account.mjs +264 -0
- package/cli/codegen.mjs +1069 -0
- package/cli/framework.mjs +244 -0
- package/cli/index.mjs +563 -0
- package/cli/log.mjs +177 -0
- package/cli/scan.mjs +84 -0
- package/cli/template.mjs +152 -0
- package/cli/tunnel.mjs +140 -0
- package/cli/validate.mjs +248 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +212 -0
- package/dist/server.js.map +1 -0
- package/dist/web.d.ts +39 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +35 -0
- package/dist/web.js.map +1 -0
- package/package.json +88 -0
- package/src/index.ts +138 -0
- package/src/server.ts +285 -0
- package/src/web.tsx +68 -0
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
|
+
}
|