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