@waniwani/kit 0.1.6 → 0.1.8

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