@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.
@@ -0,0 +1,1069 @@
1
+ /**
2
+ * Turn an app folder into a complete framework project.
3
+ *
4
+ * The plumbing comes from the distribution template repo, consumed as-is at a
5
+ * pinned commit (see `./template.mjs`). Nothing is forked into this package, so
6
+ * what a customer deploys is the same tree that is published, readable, and
7
+ * cloneable on GitHub. Only files that depend on the app's contents are
8
+ * generated here.
9
+ *
10
+ * The template owns the server. It constructs it, registers whatever tools it
11
+ * ships, and runs it; the generator writes one file into that tree —
12
+ * `src/waniwani.ts` — holding the app's identity and its registrations. A tool
13
+ * added to the template therefore reaches every app built on it, which is the
14
+ * same one-publish mechanism that carries a bug fix.
15
+ *
16
+ * Two layouts come out of the same generator:
17
+ *
18
+ * - `build` — writes `.waniwani/`, the equivalent of `.next/`. Disposable,
19
+ * gitignored, regenerated on every command. The app source is copied under
20
+ * `src/app/` so the output is self-contained, and `@waniwani/kit` is an
21
+ * ordinary dependency of it.
22
+ *
23
+ * - `eject` — writes the same plumbing into the app repo itself, moving the
24
+ * app's source under `src/app/` as it goes (the framework compiles from
25
+ * `src/` and nothing outside it can be an input). Here the runtime is
26
+ * vendored in as readable source and every `@waniwani/kit` specifier is
27
+ * rewritten to point at it, so the result is an ordinary project on the
28
+ * underlying framework, with no dependency on this CLI, this package, or
29
+ * Waniwani.
30
+ */
31
+
32
+ import {
33
+ cpSync,
34
+ existsSync,
35
+ mkdirSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ rmSync,
39
+ statSync,
40
+ writeFileSync,
41
+ } from "node:fs";
42
+ import { basename, dirname, join, relative } from "node:path";
43
+ import { fileURLToPath } from "node:url";
44
+
45
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
46
+ const RUNTIME_SRC = join(PACKAGE_ROOT, "src");
47
+ const PACKAGE_VERSION = JSON.parse(
48
+ readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"),
49
+ ).version;
50
+
51
+ /**
52
+ * The template comes across whole, minus an explicit list.
53
+ *
54
+ * The direction matters more than the contents. A denylist fails loudly: a
55
+ * plumbing file the template grows arrives in every app on its own, and
56
+ * anything that does not belong shows up in the next build and costs one line
57
+ * to exclude. An allowlist fails silently in the other direction — a new
58
+ * plumbing file is dropped without a word, and the gap surfaces in production.
59
+ * The same silence lets `package.json` be taken wholesale while the files its
60
+ * scripts and devDependencies reference are not, which leaves every generated
61
+ * project holding dangling references.
62
+ *
63
+ * A template can carry its own list in `waniwani.template.json`, and that is
64
+ * the version that counts: the contract lives in the repo where the change
65
+ * happens, so a PR adding a plumbing file declares it in the same commit. The
66
+ * defaults below cover a template that ships no manifest.
67
+ */
68
+ const MANIFEST_FILE = "waniwani.template.json";
69
+
70
+ /**
71
+ * Never copied, whatever the manifest says.
72
+ *
73
+ * `package.json` and `tsconfig.json` are absent on purpose — they are copied
74
+ * and then overwritten by generated versions, so excluding them would only make
75
+ * the ordering harder to follow.
76
+ */
77
+ const ALWAYS_EXCLUDE = [
78
+ ".git/",
79
+ "node_modules/",
80
+ MANIFEST_FILE,
81
+ // The generator rewrites package.json — merging the app's dependencies and
82
+ // applying its own pins — so a lockfile for the template's own dependency
83
+ // set describes a tree the output does not have. Worse than no lockfile.
84
+ "bun.lock",
85
+ "bun.lockb",
86
+ "package-lock.json",
87
+ "pnpm-lock.yaml",
88
+ "yarn.lock",
89
+ ];
90
+
91
+ /**
92
+ * The fallback list, for a template with no manifest of its own.
93
+ *
94
+ * `src/` is absent from it, all of it. What a template registers in
95
+ * `src/server.ts` is shipped rather than demonstrative: it reaches every app
96
+ * built on the template, and adding a tool there is how one reaches all of them
97
+ * at once. The generator adds to that tree instead of replacing it.
98
+ */
99
+ const DEFAULT_EXCLUDE = [
100
+ // Build output, if the template has any committed. Copying it forward would
101
+ // ship dead bundles for views that do not exist.
102
+ "public/",
103
+ "dist/",
104
+ // The template's identity, not the app's. Its MIT LICENSE in a customer's
105
+ // private repo is confusing at best.
106
+ "LICENSE",
107
+ "README.md",
108
+ ];
109
+
110
+ /**
111
+ * Additionally excluded from `.waniwani/`, which is disposable build output
112
+ * rather than a repo a human works in.
113
+ */
114
+ const DEFAULT_BUILD_EXCLUDE = [
115
+ // A .gitignore inside the output would stop `vercel deploy` uploading
116
+ // anything at all.
117
+ ".gitignore",
118
+ // Authoring skills and editor settings earn their place in a repo someone
119
+ // edits. In an upload they are dead weight.
120
+ ".claude/",
121
+ ".agents/",
122
+ ".vscode/",
123
+ "skills-lock.json",
124
+ ];
125
+
126
+ /**
127
+ * Files an ejected repo already owns. The template's version is written only
128
+ * when the app has none, so ejecting never overwrites a decision the app made.
129
+ */
130
+ const DEFAULT_PRESERVE = [".env.example", ".nvmrc", "biome.json", ".editorconfig"];
131
+
132
+ /**
133
+ * The template's shape, asserted rather than assumed. Copying is driven by the
134
+ * denylist, but a template missing one of these has moved in a way the
135
+ * generator cannot absorb, and failing here beats shipping a broken project.
136
+ *
137
+ * The style entry is load-bearing rather than decorative: it is the Tailwind
138
+ * entry every generated view imports, so a template without it builds green and
139
+ * serves widgets with no styling at all — every utility class in every `ui.tsx`
140
+ * resolving to nothing. That is worth failing for at the same volume as a
141
+ * missing `vite.config.ts`.
142
+ */
143
+ const STYLE_ENTRY = "src/index.css";
144
+ const REQUIRED = ["vite.config.ts", "package.json", "tsconfig.json", STYLE_ENTRY];
145
+
146
+ /**
147
+ * The seam the template has to call, and the file it lives in.
148
+ *
149
+ * Without the call there is no error to see: the generator still writes
150
+ * `src/waniwani.ts`, the build still succeeds, and the server still starts —
151
+ * serving the template's own tools and none of the app's, under the template's
152
+ * name. A green build that ships the wrong product is worth failing for.
153
+ */
154
+ const SEAM = { file: "src/server.ts", symbol: "registerApp" };
155
+
156
+ /**
157
+ * Dependency decisions the runtime makes on every app's behalf, overriding
158
+ * whatever the template declares. This is the fleet-wide fix mechanism: a
159
+ * version problem is corrected once here rather than in 30 repos.
160
+ *
161
+ * Each entry carries its reason, and the CLI reports what it changed.
162
+ */
163
+ /** Forced to an exact version: the generated code is built against these. */
164
+ const PINS = {
165
+ dependencies: {
166
+ skybridge: {
167
+ version: "1.4.0",
168
+ why: "the template's range floats within 1.x; the runtime is built and verified against this one",
169
+ },
170
+ "@waniwani/sdk": { version: "^0.19.5", why: "flows and tracking need the current SDK" },
171
+ },
172
+ devDependencies: {
173
+ "@skybridge/devtools": { version: "1.4.0", why: "must match the framework" },
174
+ },
175
+ };
176
+
177
+ /** Added only when absent, so a template that declares a newer one keeps it. */
178
+ const ENSURED = {
179
+ dependencies: {},
180
+ devDependencies: {
181
+ // Both are undeclared dependencies of the framework's dev command: it spawns
182
+ // `tsx src/server.ts` under nodemon and imports nodemon directly, while
183
+ // declaring neither.
184
+ tsx: { version: "^4.20.6", why: "the dev command shells out to tsx" },
185
+ nodemon: { version: "^3.1.10", why: "the dev command imports nodemon" },
186
+ },
187
+ };
188
+
189
+ /**
190
+ * Scripts the generated layout needs, added only when the template has no
191
+ * script by that name. The template's own scripts are left untouched.
192
+ */
193
+ const SCRIPT_ADDITIONS = {
194
+ typecheck: { command: "tsc --noEmit", why: "no typecheck script in the template" },
195
+ };
196
+
197
+ /**
198
+ * Scripts that point at files an app does not have. The template's package.json
199
+ * is taken wholesale, so a script serving only the example survives the copy
200
+ * and lands in every project as a command that fails when run.
201
+ */
202
+ const SCRIPT_REMOVALS = {
203
+ "kb:ingest": {
204
+ why: "ingests knowledge-base/, which is the example's; an app's docs are docs/*.md, inlined at build",
205
+ },
206
+ };
207
+
208
+ /**
209
+ * Where each layout puts things, relative to the project root.
210
+ *
211
+ * Both put the app's source under `src/app/`, and they have no choice. The
212
+ * framework compiles with `rootDir` pinned to `${configDir}/src` and emits an entry
213
+ * wrapper that does a literal `await import("./server.js")` next to it, so the
214
+ * compiled server has to land at `dist/server.js` and every input has to sit
215
+ * under `src/`. Source left at the repo root is outside `rootDir` and fails to
216
+ * compile (TS6059); widening `rootDir` to `.` compiles but pushes the server to
217
+ * `dist/src/server.js`, where the wrapper cannot find it.
218
+ *
219
+ * So the layouts differ in where they write and how they reach the runtime, not
220
+ * in how they arrange source:
221
+ *
222
+ * - `build` writes to `.waniwani/` and depends on `@waniwani/kit` by name.
223
+ * - `eject` writes to the app repo and vendors the runtime as source.
224
+ */
225
+ const LAYOUTS = {
226
+ build: { appDir: "src/app", runtimeDir: "src/_runtime", vendored: false },
227
+ eject: { appDir: "src/app", runtimeDir: "src/_runtime", vendored: true },
228
+ };
229
+
230
+ /** Where the app's source sits, as seen from `src/`. */
231
+ function appFrom(layout) {
232
+ return `./${basename(layout.appDir)}`;
233
+ }
234
+
235
+ /** Files and folders that are never part of an app's source. */
236
+ const NOT_SOURCE = new Set([
237
+ ".waniwani",
238
+ "node_modules",
239
+ "package.json",
240
+ "dist",
241
+ "public",
242
+ ".env",
243
+ ".env.local",
244
+ // Generated in an ejected repo. Copying them back in would fold one eject's
245
+ // output into the next one's input.
246
+ "src",
247
+ ".skybridge",
248
+ ".vercel",
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. Docs the app
251
+ // actually serves live in `docs/*.md` and are inlined separately, so nothing
252
+ // is lost by skipping these at every level.
253
+ "README.md",
254
+ "LICENSE",
255
+ // Lockfiles describe the repo's install, and the generated package.json is
256
+ // not the one they were resolved against.
257
+ "bun.lock",
258
+ "bun.lockb",
259
+ "package-lock.json",
260
+ "pnpm-lock.yaml",
261
+ "yarn.lock",
262
+ ]);
263
+
264
+ /**
265
+ * Files the generator writes itself, on top of whatever the template ships.
266
+ *
267
+ * `src/server.ts` is not among them. The template owns it, registers its own
268
+ * tools in it, and reads `src/waniwani.ts` — the one file this generates into
269
+ * the template's tree.
270
+ */
271
+ const GENERATED = ["src/waniwani.ts", "src/docs.ts", "tsconfig.json", ".template.json"];
272
+
273
+ /** `select-plan` -> `selectPlan`, for generated identifiers. */
274
+ function camel(name) {
275
+ return name.replace(/[-_](.)/g, (_, char) => char.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
276
+ }
277
+
278
+ /** Every file under `dir`, depth first. */
279
+ function* walk(dir) {
280
+ for (const entry of readdirSync(dir)) {
281
+ const path = join(dir, entry);
282
+ if (statSync(path).isDirectory()) {
283
+ yield* walk(path);
284
+ } else {
285
+ yield path;
286
+ }
287
+ }
288
+ }
289
+
290
+ function write(file, contents) {
291
+ mkdirSync(dirname(file), { recursive: true });
292
+ writeFileSync(file, contents);
293
+ }
294
+
295
+ /** Every file under `dir` as a path relative to it, slash-separated. */
296
+ function* relativeFiles(dir, prefix = "") {
297
+ for (const entry of readdirSync(dir)) {
298
+ const path = join(dir, entry);
299
+ const rel = prefix ? `${prefix}/${entry}` : entry;
300
+ if (statSync(path).isDirectory()) {
301
+ yield* relativeFiles(path, rel);
302
+ } else {
303
+ yield rel;
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * A pattern ending in `/` matches a directory and everything under it;
310
+ * anything else matches one exact path. Deliberately not globs — an exclusion
311
+ * list is read far more often than it is written, and `server/src/faq/` says
312
+ * what it does without anyone having to reason about precedence.
313
+ */
314
+ function matches(path, patterns) {
315
+ return patterns.some((pattern) =>
316
+ pattern.endsWith("/") ? path === pattern.slice(0, -1) || path.startsWith(pattern) : path === pattern,
317
+ );
318
+ }
319
+
320
+ /** The template's own exclusion list, when it ships one. */
321
+ function readManifest(template) {
322
+ const path = join(template.dir, MANIFEST_FILE);
323
+ if (!existsSync(path)) return null;
324
+ try {
325
+ return parseJsonc(readFileSync(path, "utf-8"));
326
+ } catch (cause) {
327
+ throw new Error(`the template's ${MANIFEST_FILE} is not valid JSON: ${cause.message}`);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * What this template says stays behind, falling back to the defaults when it
333
+ * says nothing. A manifest replaces the defaults rather than extending them —
334
+ * a template that has thought about the question should not have to work
335
+ * around a list written for one that has not.
336
+ *
337
+ * @returns `{ exclude, preserve, manifest }`
338
+ */
339
+ function resolveExclusions(template, layoutName) {
340
+ const manifest = readManifest(template);
341
+
342
+ return {
343
+ manifest,
344
+ exclude: [
345
+ ...ALWAYS_EXCLUDE,
346
+ ...(manifest?.exclude ?? DEFAULT_EXCLUDE),
347
+ ...(layoutName === "build" ? (manifest?.buildExclude ?? DEFAULT_BUILD_EXCLUDE) : []),
348
+ ],
349
+ preserve: manifest?.preserve ?? DEFAULT_PRESERVE,
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Add whatever the template ignores that the app does not already, without
355
+ * disturbing a line the app wrote. An ejected repo inherits `dist/`,
356
+ * `public/assets/`, and `*.tsbuildinfo` this way instead of committing them.
357
+ */
358
+ function mergeGitignore(destination, source) {
359
+ const existing = existsSync(destination) ? readFileSync(destination, "utf-8") : "";
360
+ const known = new Set(existing.split("\n").map((line) => line.trim()));
361
+ const additions = readFileSync(source, "utf-8")
362
+ .split("\n")
363
+ .filter((line) => line.trim() && !line.trim().startsWith("#") && !known.has(line.trim()));
364
+
365
+ if (additions.length === 0) return false;
366
+ const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
367
+ writeFileSync(destination, `${existing}${prefix}\n# from the waniwani template\n${additions.join("\n")}\n`);
368
+ return true;
369
+ }
370
+
371
+ /**
372
+ * Copy the template into the output, minus the exclusions.
373
+ *
374
+ * Precedence is template < app < generated: this runs before the app's source
375
+ * and before the generated files, so both win any collision. `preserve` is the
376
+ * one exception, and it only applies when ejecting — there the destination is
377
+ * the app's own repo, so a file it already owns outranks the template's. In a
378
+ * build the destination is generated, and letting a previous build's copy win
379
+ * would freeze the template at whatever version first produced the directory.
380
+ */
381
+ function copyTemplate(template, root, { layout, exclude, preserve }) {
382
+ const copied = [];
383
+
384
+ for (const file of relativeFiles(template.dir)) {
385
+ if (matches(file, exclude)) continue;
386
+
387
+ const destination = join(root, file);
388
+
389
+ if (layout.vendored && existsSync(destination)) {
390
+ if (matches(file, preserve)) continue;
391
+ if (file === ".gitignore") {
392
+ if (mergeGitignore(destination, join(template.dir, file))) copied.push(file);
393
+ continue;
394
+ }
395
+ }
396
+
397
+ mkdirSync(dirname(destination), { recursive: true });
398
+ cpSync(join(template.dir, file), destination);
399
+ copied.push(file);
400
+ }
401
+
402
+ return copied;
403
+ }
404
+
405
+ /** Config files in the wild carry comments; `JSON.parse` does not. */
406
+ function parseJsonc(source) {
407
+ let out = "";
408
+ let inString = false;
409
+ let inLine = false;
410
+ let inBlock = false;
411
+
412
+ for (let i = 0; i < source.length; i++) {
413
+ const char = source[i];
414
+ const next = source[i + 1];
415
+
416
+ if (inLine) {
417
+ if (char === "\n") {
418
+ inLine = false;
419
+ out += char;
420
+ }
421
+ continue;
422
+ }
423
+ if (inBlock) {
424
+ if (char === "*" && next === "/") {
425
+ inBlock = false;
426
+ i++;
427
+ }
428
+ continue;
429
+ }
430
+ if (inString) {
431
+ out += char;
432
+ if (char === "\\") {
433
+ out += source[++i] ?? "";
434
+ } else if (char === '"') {
435
+ inString = false;
436
+ }
437
+ continue;
438
+ }
439
+ if (char === '"') {
440
+ inString = true;
441
+ out += char;
442
+ continue;
443
+ }
444
+ if (char === "/" && next === "/") {
445
+ inLine = true;
446
+ i++;
447
+ continue;
448
+ }
449
+ if (char === "/" && next === "*") {
450
+ inBlock = true;
451
+ i++;
452
+ continue;
453
+ }
454
+ out += char;
455
+ }
456
+
457
+ // Trailing commas are common in hand-edited configs.
458
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
459
+ }
460
+
461
+ function readTemplateJson(template, file) {
462
+ const path = join(template.dir, file);
463
+ if (!existsSync(path)) {
464
+ throw new Error(
465
+ `the template at ${template.source} has no ${file} — the generator expects one`,
466
+ );
467
+ }
468
+ return parseJsonc(readFileSync(path, "utf-8"));
469
+ }
470
+
471
+ /**
472
+ * Rewrite `@waniwani/kit` imports to relative paths into the vendored runtime,
473
+ * so the output runs under plain node with no path mapping.
474
+ *
475
+ * The quotes are part of the pattern, and the subpath alternation is closed:
476
+ * `@waniwani/sdk` and `@waniwani/kit/anything-else` cannot match, so the app's
477
+ * other Waniwani imports survive an eject untouched.
478
+ */
479
+ function rewriteRuntimeImports(source, fromFile, outDir, runtimeDir) {
480
+ const toRuntime = (file) => {
481
+ const path = relative(dirname(fromFile), join(outDir, runtimeDir, file)).replace(/\\/g, "/");
482
+ return path.startsWith(".") ? path : `./${path}`;
483
+ };
484
+
485
+ return source.replace(/(["'])@waniwani\/kit(\/(?:web|server))?\1/g, (_match, quote, subpath) => {
486
+ const file = subpath === "/web" ? "web.js" : subpath === "/server" ? "server.js" : "index.js";
487
+ return `${quote}${toRuntime(file)}${quote}`;
488
+ });
489
+ }
490
+
491
+ /** Point every copied or in-place source file at the vendored runtime. */
492
+ function rewriteTree(dir, outDir, runtimeDir) {
493
+ for (const file of walk(dir)) {
494
+ if (!/\.(ts|tsx|mts|js|jsx)$/.test(file)) continue;
495
+ const contents = readFileSync(file, "utf-8");
496
+ const rewritten = rewriteRuntimeImports(contents, file, outDir, runtimeDir);
497
+ if (rewritten !== contents) {
498
+ writeFileSync(file, rewritten);
499
+ }
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Copy the app source into the output. The whole folder comes across, not just
505
+ * the convention directories, so an app can keep shared modules (`lib/`,
506
+ * `data/`, whatever) and import them relatively as in any other project.
507
+ *
508
+ * `cpSync` refuses to copy a directory into itself and the build output lives
509
+ * inside the app, so the tree is walked by hand.
510
+ */
511
+ function copyAppSource(from, to) {
512
+ mkdirSync(to, { recursive: true });
513
+ for (const entry of readdirSync(from)) {
514
+ if (!isAppSource(from, entry)) continue;
515
+ const source = join(from, entry);
516
+ const destination = join(to, entry);
517
+ if (statSync(source).isDirectory()) {
518
+ copyAppSource(source, destination);
519
+ } else {
520
+ cpSync(source, destination);
521
+ }
522
+ }
523
+ }
524
+
525
+ /**
526
+ * One definition of "the app's source", so a move cannot delete something the
527
+ * copy did not take. Dotfiles are tooling rather than source, except the ones an
528
+ * app repo needs.
529
+ */
530
+ function isAppSource(dir, entry) {
531
+ if (NOT_SOURCE.has(entry)) return false;
532
+ if (entry.startsWith(".") && entry !== ".env.example") return false;
533
+ return existsSync(join(dir, entry));
534
+ }
535
+
536
+ /**
537
+ * Delete the originals after an in-place eject has copied them under `src/app/`.
538
+ * Driven by the same predicate as the copy, so the two cannot disagree about
539
+ * what counts as source.
540
+ *
541
+ * @returns the top-level entries removed, for the CLI to report
542
+ */
543
+ function removeAppSource(appRoot) {
544
+ const removed = [];
545
+ for (const entry of readdirSync(appRoot)) {
546
+ if (!isAppSource(appRoot, entry)) continue;
547
+ rmSync(join(appRoot, entry), { recursive: true, force: true });
548
+ removed.push(entry);
549
+ }
550
+ return removed;
551
+ }
552
+
553
+ /**
554
+ * Origins the template's Tailwind entry loads from, for the widget CSP.
555
+ *
556
+ * Every generated view imports `src/index.css`, so whatever it reaches out to is
557
+ * reached out to by every widget in every app. A host that enforces the widget
558
+ * CSP — ChatGPT does — drops those requests unless the tool declares the origin,
559
+ * and a blocked webfont does not error: the design token `--font-sans: "Inter"`
560
+ * just falls through to `system-ui`, and the widget looks subtly wrong. Reading
561
+ * it off the stylesheet keeps the two in step without an app author knowing the
562
+ * template's font is a font at all.
563
+ *
564
+ * Companions cover the split-origin case, where fetching the declared URL
565
+ * produces requests to a second host that no amount of reading this file can
566
+ * reveal: `fonts.googleapis.com` serves a stylesheet whose `src` points at
567
+ * `fonts.gstatic.com`. Declaring the first without the second buys nothing.
568
+ */
569
+ const STYLE_ORIGIN_COMPANIONS = {
570
+ "https://fonts.googleapis.com": ["https://fonts.gstatic.com"],
571
+ };
572
+
573
+ function templateStyleDomains(template) {
574
+ const css = readFileSync(join(template.dir, STYLE_ENTRY), "utf-8");
575
+ const origins = new Set();
576
+
577
+ for (const match of css.matchAll(/https:\/\/[^\s"')]+/g)) {
578
+ let origin;
579
+ try {
580
+ origin = new URL(match[0]).origin;
581
+ } catch {
582
+ continue;
583
+ }
584
+ origins.add(origin);
585
+ for (const companion of STYLE_ORIGIN_COMPANIONS[origin] ?? []) {
586
+ origins.add(companion);
587
+ }
588
+ }
589
+
590
+ return [...origins].sort();
591
+ }
592
+
593
+ // ------------------------------------------------------------ generated files
594
+
595
+ function generateServerApp(app, layout, { runtime, styleDomains }) {
596
+ const from = appFrom(layout);
597
+
598
+ const imports = [
599
+ `import { config as loadEnv } from "dotenv";`,
600
+ `import type { McpServer } from "skybridge/server";`,
601
+ `import { registerApp as register } from "${runtime.server}";`,
602
+ app.docs.length > 0 ? `import { docs } from "./docs.js";` : null,
603
+ `import config from "${from}/waniwani.config.js";`,
604
+ ...app.tools.map((t) => `import tool_${camel(t.name)} from "${from}/tools/${t.name}.js";`),
605
+ ...app.widgets.map(
606
+ (w) => `import widget_${camel(w.name)} from "${from}/widgets/${w.name}/widget.js";`,
607
+ ),
608
+ ...app.flows.map((f) => `import flow_${camel(f.name)} from "${from}/flows/${f.name}.js";`),
609
+ ].filter(Boolean);
610
+
611
+ const list = (items) => (items.length === 0 ? "[]" : `[\n\t\t${items.join(",\n\t\t")},\n\t]`);
612
+
613
+ return `// Generated from the app folder. The seam \`src/server.ts\` reads: the
614
+ // template owns the server, and this is what the app adds to it.
615
+ ${imports.join("\n")}
616
+
617
+ // The app's .env may sit at the project root or one level above it, depending
618
+ // on whether this is a generated build or an ejected project.
619
+ loadEnv({ path: ["../.env", ".env"], quiet: true });
620
+
621
+ export const app = {
622
+ name: config.name,
623
+ title: config.title,
624
+ version: config.version ?? "0.0.0",
625
+ instructions: config.instructions,
626
+ };
627
+
628
+ export async function registerApp(server: McpServer): Promise<void> {
629
+ await register(server, {
630
+ tools: ${list(app.tools.map((t) => `{ name: "${t.name}", def: tool_${camel(t.name)} }`))},
631
+ widgets: ${list(app.widgets.map((w) => `{ name: "${w.name}", def: widget_${camel(w.name)} }`))},
632
+ flows: ${list(app.flows.map((f) => `flow_${camel(f.name)}`))},
633
+ docs: ${app.docs.length > 0 ? "docs" : "[]"},
634
+ // Read off the template's ${STYLE_ENTRY}, which every view imports.
635
+ styleDomains: ${list(styleDomains.map((origin) => `"${origin}"`))},
636
+ });
637
+ }
638
+ `;
639
+ }
640
+
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
+ function generateWidgetShim(widget, layout) {
658
+ // From `src/views/` up to `src/`, then out to the app's source.
659
+ const from = `../${basename(layout.appDir)}`;
660
+ const dir = `${from}/widgets/${widget.name}`;
661
+
662
+ // The one stylesheet, and the only one: the template's Tailwind entry, which
663
+ // carries the `@theme` tokens, the `dark` variant, and the base layer. Each
664
+ // view is a separate bundle, so every one of them pulls it in for itself, and
665
+ // Tailwind emits only the utilities that view's source actually uses.
666
+ //
667
+ // No app CSS is imported here on purpose. A widget's styling is utility
668
+ // classes in its `ui.tsx`, which is one file to read instead of two and one
669
+ // place for a class name to exist. It also sidesteps Tailwind v4's
670
+ // `@reference` requirement: `@apply` in a CSS file that does not itself
671
+ // import Tailwind is a build error, and an app's CSS could never import the
672
+ // entry by a path that is valid both in the author's repo and in this tree.
673
+ //
674
+ // The framework discovers views by scanning for a default export and mounts them
675
+ // itself — a file without one is scanned as invalid and dropped from the
676
+ // bundle, taking its manifest entry with it and failing only at
677
+ // `resources/read`. The detector is a regex over the source, and it matches
678
+ // neither `export { default } from "…"` nor a bare re-export, so the import
679
+ // and the export are written out separately.
680
+ return `// Generated from widgets/${widget.name}/. The mounted view entry.
681
+ import "../index.css";
682
+ import Component from "${dir}/ui.js";
683
+
684
+ export default Component;
685
+ `;
686
+ }
687
+
688
+ /**
689
+ * The template's tsconfig, with the two changes the generated layout needs.
690
+ * Everything else — target, strictness, JSX — stays whatever the template says.
691
+ */
692
+ function generateTsconfig(template, layout) {
693
+ const base = readTemplateJson(template, "tsconfig.json");
694
+
695
+ return {
696
+ ...base,
697
+ // The template resolves the framework through its own node_modules; the
698
+ // output's node_modules lives at the deployment root instead.
699
+ extends: "skybridge/tsconfig",
700
+ compilerOptions: {
701
+ ...base.compilerOptions,
702
+ // Generated code is not the app author's to fix.
703
+ noUnusedLocals: false,
704
+ noUnusedParameters: false,
705
+ },
706
+ // Both layouts keep everything under `src/`, which the template's own
707
+ // include already covers. The dotted directory holds generated view types.
708
+ include: ["src", ".skybridge/**/*.d.ts"],
709
+ exclude: ["node_modules", "dist", ".waniwani"],
710
+ };
711
+ }
712
+
713
+ /**
714
+ * The template's biome config scopes itself to `server/**` and `web/**` — the
715
+ * only source it has. An app's source lives elsewhere, so a copied config
716
+ * lints nothing the author wrote and `npm run lint` passes vacuously.
717
+ *
718
+ * @returns the adjusted config, or null if the template ships none
719
+ */
720
+ function generateBiome(template, layout) {
721
+ const path = join(template.dir, "biome.json");
722
+ if (!existsSync(path)) return null;
723
+
724
+ const base = parseJsonc(readFileSync(path, "utf-8"));
725
+ const includes = base.files?.includes;
726
+ if (!Array.isArray(includes)) return base;
727
+
728
+ // Negated patterns are exclusions and have to stay last to keep their effect.
729
+ const positive = includes.filter((pattern) => !pattern.startsWith("!"));
730
+ const negative = includes.filter((pattern) => pattern.startsWith("!"));
731
+ const app = [`${layout.appDir}/**`];
732
+
733
+ return {
734
+ ...base,
735
+ files: {
736
+ ...base.files,
737
+ includes: [
738
+ ...positive,
739
+ ...app.filter((pattern) => !positive.includes(pattern)),
740
+ // Generated and vendored code is not the app author's to fix.
741
+ `!${layout.runtimeDir}/**`,
742
+ "!src/server.ts",
743
+ "!src/docs.ts",
744
+ "!src/views/**",
745
+ ...negative,
746
+ ],
747
+ },
748
+ };
749
+ }
750
+
751
+ /**
752
+ * The template's package.json is the source of truth for dependencies and
753
+ * scripts; the runtime layers its overrides on top.
754
+ *
755
+ * @returns `{ packageJson, overrides }` — overrides for the CLI to report
756
+ */
757
+ function generatePackageJson(app, appPackageJson, template, layout) {
758
+ const base = readTemplateJson(template, "package.json");
759
+ const overrides = [];
760
+
761
+ /**
762
+ * Merge the template's declarations with the app's, then apply the
763
+ * runtime's. An app that declares a pinned package itself keeps its own
764
+ * choice — it is their repo — but the disagreement is reported.
765
+ */
766
+ const apply = (kind, appDeps) => {
767
+ const merged = { ...base[kind], ...appDeps };
768
+
769
+ for (const [name, { version, why }] of Object.entries(PINS[kind] ?? {})) {
770
+ if (appDeps[name] && appDeps[name] !== version) {
771
+ overrides.push({
772
+ name,
773
+ to: appDeps[name],
774
+ why: `the app pins this itself — the runtime is built against ${version}`,
775
+ conflict: true,
776
+ });
777
+ continue;
778
+ }
779
+ if (merged[name] !== version) {
780
+ overrides.push({ name, from: base[kind]?.[name], to: version, why });
781
+ }
782
+ merged[name] = version;
783
+ }
784
+
785
+ for (const [name, { version, why }] of Object.entries(ENSURED[kind] ?? {})) {
786
+ if (merged[name]) continue;
787
+ merged[name] = version;
788
+ overrides.push({ name, to: version, why });
789
+ }
790
+
791
+ return merged;
792
+ };
793
+
794
+ const scripts = { ...base.scripts };
795
+ for (const [name, { command, why }] of Object.entries(SCRIPT_ADDITIONS)) {
796
+ if (scripts[name]) continue;
797
+ scripts[name] = command;
798
+ overrides.push({ name: `scripts.${name}`, to: command, why });
799
+ }
800
+ for (const [name, { why }] of Object.entries(SCRIPT_REMOVALS)) {
801
+ if (!scripts[name]) continue;
802
+ delete scripts[name];
803
+ overrides.push({ name: `scripts.${name}`, removed: true, why });
804
+ }
805
+
806
+ // An ejected project drops @waniwani/kit — its runtime is vendored in as
807
+ // source. A build keeps it: the generated `src/waniwani.ts` imports it by
808
+ // name like any other dependency.
809
+ const declared = appPackageJson?.dependencies ?? {};
810
+ const { "@waniwani/kit": runtimeDep, ...rest } = declared;
811
+ const appDependencies = layout.vendored ? rest : declared;
812
+
813
+ // A workspace protocol resolves only inside this monorepo, and the output is
814
+ // meant to install anywhere. Fall back to the version of the CLI producing it.
815
+ if (appDependencies["@waniwani/kit"]?.startsWith("workspace:")) {
816
+ appDependencies["@waniwani/kit"] = `^${PACKAGE_VERSION}`;
817
+ overrides.push({
818
+ name: "@waniwani/kit",
819
+ from: runtimeDep,
820
+ to: `^${PACKAGE_VERSION}`,
821
+ why: "a workspace dependency does not resolve outside this repo",
822
+ });
823
+ }
824
+
825
+ const name = appPackageJson?.name ?? basename(app.root);
826
+
827
+ return {
828
+ packageJson: {
829
+ ...base,
830
+ // A build's package.json describes `.waniwani/`, which is not the app.
831
+ name: layout.vendored ? name : `${name}-build`,
832
+ version: appPackageJson?.version ?? base.version,
833
+ description: undefined,
834
+ private: true,
835
+ type: "module",
836
+ scripts,
837
+ dependencies: apply("dependencies", appDependencies),
838
+ devDependencies: apply("devDependencies", appPackageJson?.devDependencies ?? {}),
839
+ },
840
+ overrides,
841
+ };
842
+ }
843
+
844
+ /**
845
+ * Refuse a template whose server never calls into the generated seam.
846
+ *
847
+ * A textual check rather than a structural one: it runs before anything is
848
+ * written, on a file the generator does not own, and every way of satisfying it
849
+ * is a way of actually calling the function.
850
+ */
851
+ function assertSeam(template) {
852
+ const path = join(template.dir, SEAM.file);
853
+ if (!existsSync(path)) {
854
+ throw new Error(
855
+ `the template at ${template.source} has no ${SEAM.file} — ` +
856
+ "its layout moved and the generator needs updating",
857
+ );
858
+ }
859
+
860
+ if (readFileSync(path, "utf-8").includes(SEAM.symbol)) return;
861
+
862
+ throw new Error(
863
+ `the template at ${template.source} never calls ${SEAM.symbol}(), so this app's\n` +
864
+ ` tools, widgets, flows, and docs would be built and then silently dropped.\n\n` +
865
+ ` Add to its ${SEAM.file}:\n\n` +
866
+ ` import { app, registerApp } from "./waniwani.js";\n\n` +
867
+ ` const server = new McpServer(\n` +
868
+ ` { name: app.name, title: app.title, version: app.version },\n` +
869
+ ` { capabilities: {}, instructions: app.instructions },\n` +
870
+ ` );\n\n` +
871
+ ` await ${SEAM.symbol}(server); // before withWaniwani()\n`,
872
+ );
873
+ }
874
+
875
+ /** What the previous build recorded in `.template.json`, if there was one. */
876
+ function readProvenance(root) {
877
+ const path = join(root, ".template.json");
878
+ if (!existsSync(path)) return null;
879
+ try {
880
+ return JSON.parse(readFileSync(path, "utf-8"));
881
+ } catch {
882
+ // A corrupt provenance file costs a stale file or two, not a build.
883
+ return null;
884
+ }
885
+ }
886
+
887
+ /** Keep `.waniwani/` out of the app repo, the way `.next/` is kept out. */
888
+ function ignoreBuildOutput(appRoot) {
889
+ const file = join(appRoot, ".gitignore");
890
+ const existing = existsSync(file) ? readFileSync(file, "utf-8") : "";
891
+ if (existing.split("\n").some((line) => line.trim().replace(/\/$/, "") === ".waniwani")) {
892
+ return;
893
+ }
894
+ const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
895
+ writeFileSync(file, `${existing}${prefix}.waniwani/\n`);
896
+ }
897
+
898
+ // ----------------------------------------------------------------- generation
899
+
900
+ /**
901
+ * Plumbing files that already exist in `outDir`, so eject never clobbers.
902
+ *
903
+ * Which files count depends on the template, so this needs a resolved one.
904
+ * Files the app is allowed to own — the `preserve` set, and `.gitignore`,
905
+ * which is merged rather than replaced — are not clashes.
906
+ */
907
+ export function existingPlumbing(outDir, template) {
908
+ const { exclude, preserve } = resolveExclusions(template, "eject");
909
+
910
+ const fromTemplate = [...relativeFiles(template.dir)].filter(
911
+ (file) => !matches(file, exclude) && !matches(file, preserve) && file !== ".gitignore",
912
+ );
913
+
914
+ return [...new Set([...fromTemplate, ...GENERATED])].filter((file) =>
915
+ existsSync(join(outDir, file)),
916
+ );
917
+ }
918
+
919
+ /**
920
+ * @param app the scanned app
921
+ * @param options.template a resolved template from `resolveTemplate()`
922
+ * @param options.layout `"build"` (default) or `"eject"`
923
+ * @param options.outDir defaults to `<app>/.waniwani` for build, `<app>` for eject
924
+ * @returns `{ outDir, written, overrides }`
925
+ */
926
+ export function generate(app, { template, layout: layoutName = "build", outDir } = {}) {
927
+ if (!template?.dir) {
928
+ throw new Error("generate() needs a resolved template — call resolveTemplate() first");
929
+ }
930
+
931
+ const layout = LAYOUTS[layoutName];
932
+ const root = outDir ?? (layoutName === "build" ? join(app.root, ".waniwani") : app.root);
933
+ const written = [];
934
+ const emit = (file, contents) => {
935
+ write(join(root, file), contents);
936
+ written.push(file);
937
+ };
938
+
939
+ for (const file of REQUIRED) {
940
+ if (existsSync(join(template.dir, file))) continue;
941
+ throw new Error(
942
+ `the template at ${template.source} has no ${file} — ` +
943
+ "its layout moved and the generator needs updating",
944
+ );
945
+ }
946
+
947
+ assertSeam(template);
948
+
949
+ const { exclude, preserve, manifest } = resolveExclusions(template, layoutName);
950
+
951
+ mkdirSync(root, { recursive: true });
952
+
953
+ // A build depends on the published package like any other dependency.
954
+ // Ejecting vendors it as readable source instead — that is the whole point
955
+ // of ejecting, and it is what leaves the result with no Waniwani in it.
956
+ const vendored = layout.vendored;
957
+ const dir = `./${basename(layout.runtimeDir)}`;
958
+ // Relative specifiers carry the extension ESM resolution needs; the package
959
+ // is reached through its own exports map.
960
+ const runtime = vendored
961
+ ? { server: `${dir}/server.js`, index: `${dir}/index.js` }
962
+ : { server: "@waniwani/kit/server", index: "@waniwani/kit" };
963
+
964
+ if (vendored) {
965
+ const runtimeOut = join(root, layout.runtimeDir);
966
+ rmSync(runtimeOut, { recursive: true, force: true });
967
+ cpSync(RUNTIME_SRC, runtimeOut, { recursive: true });
968
+ written.push(`${layout.runtimeDir}/`);
969
+ }
970
+
971
+ // The app's source moves under `src/app/` in both layouts — the framework's
972
+ // `rootDir` leaves no alternative. Ejecting in place is therefore a move
973
+ // rather than a copy: the originals go once the copy is on disk, so the repo
974
+ // is left with one copy of every file rather than two that can drift.
975
+ const appOut = join(root, layout.appDir);
976
+ rmSync(appOut, { recursive: true, force: true });
977
+ copyAppSource(app.root, appOut);
978
+ const moved = root === app.root ? removeAppSource(app.root) : [];
979
+
980
+ // Only an ejected tree needs rewriting: a build reaches the runtime by
981
+ // package name, which resolves without help.
982
+ if (vendored) {
983
+ rewriteTree(appOut, root, layout.runtimeDir);
984
+ }
985
+
986
+ // Straight out of the template repo, byte for byte.
987
+ const previous = readProvenance(root);
988
+ const fromTemplate = copyTemplate(template, root, { layout, exclude, preserve });
989
+
990
+ // `.waniwani/` is not wiped between builds — `node_modules/` and `dist/`
991
+ // live there — so a file the template drops would otherwise sit in the
992
+ // output forever, and switching templates would leave the two mixed.
993
+ // Ejecting is left alone: that is a real repo, and git tracks deletions.
994
+ if (layoutName === "build") {
995
+ const current = new Set([...fromTemplate, ...GENERATED]);
996
+ for (const file of previous?.files ?? []) {
997
+ if (current.has(file)) continue;
998
+ rmSync(join(root, file), { force: true });
999
+ }
1000
+ }
1001
+
1002
+ emit(
1003
+ "src/waniwani.ts",
1004
+ generateServerApp(app, layout, { runtime, styleDomains: templateStyleDomains(template) }),
1005
+ );
1006
+ if (app.docs.length > 0) {
1007
+ emit("src/docs.ts", generateDocs(app, { runtime }));
1008
+ }
1009
+
1010
+ // `src/views/` is shared: the template's own views sit alongside the app's,
1011
+ // so it cannot be wiped. Only the entries a previous build wrote are
1012
+ // removed, which is what clears a widget the app has since deleted.
1013
+ const views = app.widgets.map((widget) => `src/views/${widget.name}.tsx`);
1014
+ for (const stale of previous?.views ?? []) {
1015
+ if (views.includes(stale) || fromTemplate.includes(stale)) continue;
1016
+ rmSync(join(root, stale), { force: true });
1017
+ }
1018
+ for (const widget of app.widgets) {
1019
+ emit(`src/views/${widget.name}.tsx`, generateWidgetShim(widget, layout));
1020
+ }
1021
+
1022
+ const appPackageJsonPath = join(app.root, "package.json");
1023
+ const appPackageJson = existsSync(appPackageJsonPath)
1024
+ ? JSON.parse(readFileSync(appPackageJsonPath, "utf-8"))
1025
+ : undefined;
1026
+
1027
+ const { packageJson, overrides } = generatePackageJson(app, appPackageJson, template, layout);
1028
+
1029
+ emit("tsconfig.json", `${JSON.stringify(generateTsconfig(template, layout), null, 2)}\n`);
1030
+ emit("package.json", `${JSON.stringify(packageJson, null, 2)}\n`);
1031
+
1032
+ // Only adjust a config this build actually placed. When an ejected repo
1033
+ // keeps its own, the app's scoping decisions are the app's to make.
1034
+ if (fromTemplate.includes("biome.json")) {
1035
+ emit("biome.json", `${JSON.stringify(generateBiome(template, layout), null, 2)}\n`);
1036
+ }
1037
+
1038
+ // Provenance: which template produced this tree, and which files came from
1039
+ // it — the second half is what lets the next build clean up after itself.
1040
+ emit(
1041
+ ".template.json",
1042
+ `${JSON.stringify(
1043
+ {
1044
+ source: template.source,
1045
+ ref: template.ref,
1046
+ sha: template.sha,
1047
+ local: template.local,
1048
+ manifest: manifest ? MANIFEST_FILE : undefined,
1049
+ // What survived to the end: the generated files land on top of
1050
+ // the copy, so the raw list overstates it.
1051
+ files: fromTemplate.filter((file) => existsSync(join(root, file))),
1052
+ // Tracked separately because `src/views/` is shared with the
1053
+ // template — the next build needs to know which entries were
1054
+ // ours before it removes any.
1055
+ views,
1056
+ },
1057
+ null,
1058
+ 2,
1059
+ )}\n`,
1060
+ );
1061
+
1062
+ if (layoutName === "build") {
1063
+ // A .gitignore inside the output would stop `vercel deploy` uploading
1064
+ // anything, so the ignore goes in the app repo instead.
1065
+ ignoreBuildOutput(app.root);
1066
+ }
1067
+
1068
+ return { outDir: root, written, overrides, fromTemplate, moved, manifest: Boolean(manifest) };
1069
+ }