@waniwani/kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The framework boundary.
3
+ *
4
+ * The generated project is built on a third-party MCP framework, and that
5
+ * framework's CLI narrates itself: a branded banner on `dev`, `build` and
6
+ * `start`, pointers at its own hosted tunnel and playground, a support link, and
7
+ * an analytics event per command. An app author is using `waniwani`, so none of
8
+ * that is output to hand them. Every place the framework speaks is intercepted
9
+ * here rather than scattered across the commands.
10
+ *
11
+ * Three seams do the work:
12
+ *
13
+ * dev `--plain` drops the interactive UI and writes each diagnostic to
14
+ * stderr as one plain line, which makes the stream rewritable.
15
+ * build the step list behind the interactive UI is plain data, so this CLI
16
+ * loads it, drives the steps, and prints the progress itself.
17
+ * start the banner is ordinary `console.log`, so stdout is rewritten the
18
+ * same way as dev's stderr.
19
+ *
20
+ * What no amount of output rewriting reaches: the devtools page served at the
21
+ * dev server's root is the framework's own UI, and the generated project names
22
+ * the framework in its `tsconfig`, its dependencies, and its type-output
23
+ * directory. Nothing here is concealment — the dependency is declared in
24
+ * `package.json` like any other. It is a house style: one CLI does the talking.
25
+ *
26
+ * Module specifiers, the dependency name, the telemetry variable and the
27
+ * patterns that have to match the framework's own strings are load-bearing and
28
+ * stay as they are. Prose does not.
29
+ */
30
+
31
+ import { existsSync } from "node:fs";
32
+ import { dirname, join } from "node:path";
33
+ import { fileURLToPath, pathToFileURL } from "node:url";
34
+ import { bold, dim, endpoint, green, red, yellow } from "./log.mjs";
35
+
36
+ /**
37
+ * The framework package root. It may be hoisted anywhere above us, so resolve
38
+ * it rather than guess: its `tsconfig` export is the only one that maps to a
39
+ * file at the package root, which makes it a reliable anchor for the directory.
40
+ */
41
+ export function frameworkDir() {
42
+ return dirname(fileURLToPath(import.meta.resolve("skybridge/tsconfig")));
43
+ }
44
+
45
+ export function frameworkBin() {
46
+ return join(frameworkDir(), "bin", "run.js");
47
+ }
48
+
49
+ /**
50
+ * Environment applied to every framework subprocess.
51
+ *
52
+ * The framework's CLI reports each command to a third-party analytics endpoint,
53
+ * keyed by a machine id it persists in the user's home directory. Running
54
+ * someone's build is not consent to that, so it is off by both switches the
55
+ * framework honours.
56
+ */
57
+ export const FRAMEWORK_ENV = {
58
+ SKYBRIDGE_TELEMETRY_DISABLED: "1",
59
+ DO_NOT_TRACK: "1",
60
+ };
61
+
62
+ /**
63
+ * Match against the text, not the colours the framework wrapped it in.
64
+ *
65
+ * ESC is built from its char code rather than written as a raw byte or an
66
+ * escape sequence. The byte is invisible in source and survives no copy or
67
+ * reformat reliably; an escape sequence gets normalised back into the byte by
68
+ * some tooling. Either way the loss is silent, and every rule below then fails
69
+ * to match any coloured line.
70
+ */
71
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
72
+
73
+ /**
74
+ * A framework command name leaking through an otherwise fine line — an error
75
+ * hint, a nodemon echo. The commands map one to one, so the name is swapped for
76
+ * ours rather than the line dropped.
77
+ */
78
+ function reword(line) {
79
+ return line.replace(/\bskybridge (build|dev|start)\b/g, "waniwani $1");
80
+ }
81
+
82
+ /**
83
+ * How each line of `dev --plain` diagnostics is rewritten.
84
+ *
85
+ * The framework's stderr is a closed set: a banner, three or four URLs, the
86
+ * tunnel's state, restart notices, and TypeScript errors. The app's own stderr
87
+ * comes through the same pipe, so an unmatched line is passed through — except
88
+ * under the emoji prefixes the framework owns, which are dropped rather than
89
+ * guessed at, since a `starting` tunnel message is verbatim output from a
90
+ * subprocess of its own and can say anything.
91
+ *
92
+ * The framework's own tunnel is one of those lines. No command here asks for it,
93
+ * so what arrives is its offer of one, and the emoji it carries drops the line.
94
+ * A public hostname comes from `waniwani tunnel` instead (see ./tunnel.mjs).
95
+ *
96
+ * Each pattern leads with `\W*` to absorb whatever emoji prefixes the line and
97
+ * ends at `$`, so a rule reads the framework's whole line and can't fire on an
98
+ * app log that happens to open with the same words.
99
+ */
100
+ const BANNER_AND_URLS = [
101
+ // Matched by shape — `<name> v1.2.3` alone on a line — which costs nothing in
102
+ // precision and keeps the framework's name out of this file.
103
+ [/^\W*\S+ v\d+\.\d+\.\d+\S*$/u, () => null],
104
+ [/^\W*(\d+) in use, running on (\S+)$/u, (m) => `${endpoint("server", m[2])} ${dim(`(${m[1]} in use)`)}`],
105
+ [/^\W*Running on (\S+)$/u, (m) => endpoint("server", m[1])],
106
+ ];
107
+
108
+ const DEV_RULES = [
109
+ ...BANNER_AND_URLS,
110
+ // The devtools page is the framework's own UI and nobody here reaches for it,
111
+ // so its URL is dropped rather than restated.
112
+ [/^\W*Test locally with DevTools: \S+$/u, () => null],
113
+ [/^\W*Server restarted due to file changes: (.*)$/u, (m) => dim(`[waniwani] restarted — ${m[1]}`)],
114
+ [/^\W*TypeScript errors found:\W*$/u, () => `${yellow("!")} ${bold("TypeScript errors")}`],
115
+ ];
116
+
117
+ /** `start` prints its banner to stdout without ever mounting the UI. */
118
+ const START_RULES = BANNER_AND_URLS;
119
+
120
+ /** Emoji the framework prefixes its own chrome with. */
121
+ const FRAMEWORK_PREFIX = /^(?:⛰|🏠|🌍|🛟|→)/u;
122
+
123
+ function rewriter(rules, { dropUnmatchedChrome }) {
124
+ return (line) => {
125
+ const text = line.replace(ANSI, "").trimEnd();
126
+ for (const [pattern, format] of rules) {
127
+ const match = pattern.exec(text);
128
+ if (match) return format(match);
129
+ }
130
+ if (dropUnmatchedChrome && FRAMEWORK_PREFIX.test(text)) return null;
131
+ return reword(line);
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Feed a subprocess stream through `rewrite` one whole line at a time, writing
137
+ * the result to `out`. A rewrite of `null` drops the line.
138
+ *
139
+ * Chunk boundaries fall anywhere, so a partial line is held until its newline
140
+ * arrives; `flush` emits whatever is left when the stream closes.
141
+ */
142
+ function lineFilter(rewrite, out) {
143
+ let buffered = "";
144
+ const emit = (line) => {
145
+ const rewritten = rewrite(line);
146
+ if (rewritten !== null) {
147
+ out.write(`${rewritten}\n`);
148
+ }
149
+ };
150
+ return {
151
+ write(chunk) {
152
+ buffered += chunk;
153
+ const lines = buffered.split("\n");
154
+ buffered = lines.pop() ?? "";
155
+ for (const line of lines) emit(line);
156
+ },
157
+ flush() {
158
+ if (buffered) {
159
+ const line = buffered;
160
+ buffered = "";
161
+ emit(line);
162
+ }
163
+ },
164
+ };
165
+ }
166
+
167
+ /** Diagnostics stay on stderr, so the app's own stdout is never in the way. */
168
+ export function devFilter() {
169
+ return lineFilter(rewriter(DEV_RULES, { dropUnmatchedChrome: true }), process.stderr);
170
+ }
171
+
172
+ export function startFilter() {
173
+ return lineFilter(rewriter(START_RULES, { dropUnmatchedChrome: false }), process.stdout);
174
+ }
175
+
176
+ /**
177
+ * The framework's build as a list of labelled steps, or null if it can't be
178
+ * reached in that form.
179
+ *
180
+ * The step list sits outside the package's `exports` map, so it is reachable
181
+ * only by absolute path — the same coupling this CLI already accepts for the
182
+ * framework's `bin`, against a version codegen pins exactly. When the module or
183
+ * its shape has moved, null tells the caller to shell out to the framework's own
184
+ * build command instead: a build that narrates itself beats no build at all.
185
+ */
186
+ export async function loadBuildSteps(root) {
187
+ const path = join(frameworkDir(), "dist", "cli", "build-steps.js");
188
+ if (!existsSync(path)) return null;
189
+
190
+ let getCommandSteps;
191
+ try {
192
+ ({ getCommandSteps } = await import(pathToFileURL(path).href));
193
+ } catch {
194
+ return null;
195
+ }
196
+ if (typeof getCommandSteps !== "function") return null;
197
+
198
+ // A throw from here on is a real build failure — a broken vite config, two
199
+ // views with one name — and belongs to the caller, not to the fallback.
200
+ const steps = await getCommandSteps(root);
201
+ const usable =
202
+ Array.isArray(steps) &&
203
+ steps.length > 0 &&
204
+ steps.every(
205
+ (step) =>
206
+ step && typeof step.label === "string" && (typeof step.run === "function" || typeof step.command === "string"),
207
+ );
208
+ return usable ? steps : null;
209
+ }
210
+
211
+ /**
212
+ * Run the loaded steps, one printed line each. `runShell` is injected so the
213
+ * caller keeps ownership of how subprocesses are spawned — PATH, environment,
214
+ * which stream they inherit.
215
+ */
216
+ export async function runBuildSteps(steps, { root, runShell }) {
217
+ // The steps read and write relative to the working directory, which is the
218
+ // generated project when the framework runs them itself.
219
+ const previousCwd = process.cwd();
220
+ process.chdir(root);
221
+ try {
222
+ for (const step of steps) {
223
+ try {
224
+ if (step.run) {
225
+ await step.run();
226
+ }
227
+ if (step.command) {
228
+ const code = await runShell(step.command);
229
+ if (code !== 0) {
230
+ console.error(` ${red("✗")} ${step.label}`);
231
+ return code;
232
+ }
233
+ }
234
+ } catch (error) {
235
+ console.error(` ${red("✗")} ${step.label}`);
236
+ throw error;
237
+ }
238
+ console.log(` ${green("✓")} ${dim(step.label)}`);
239
+ }
240
+ return 0;
241
+ } finally {
242
+ process.chdir(previousCwd);
243
+ }
244
+ }