@waniwani/kit 0.1.6 → 0.1.7

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