@waniwani/kit 0.1.6-beta.0 → 0.1.6

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/cli/codegen.mjs CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  } from "node:fs";
42
42
  import { basename, dirname, join, relative } from "node:path";
43
43
  import { fileURLToPath } from "node:url";
44
+ import { compare, floorOf, installable } from "./peers.mjs";
44
45
 
45
46
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
46
47
  const RUNTIME_SRC = join(PACKAGE_ROOT, "src");
@@ -196,10 +197,6 @@ const PINS = {
196
197
  version: declared("skybridge"),
197
198
  why: "the template's range floats within 1.x; the runtime is built and verified against this one",
198
199
  },
199
- "@waniwani/sdk": {
200
- version: declared("@waniwani/sdk"),
201
- why: "flows and tracking need the current SDK",
202
- },
203
200
  },
204
201
  devDependencies: {
205
202
  "@skybridge/devtools": {
@@ -209,6 +206,34 @@ const PINS = {
209
206
  },
210
207
  };
211
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
+
212
237
  /** Added only when absent, so a template that declares a newer one keeps it. */
213
238
  const ENSURED = {
214
239
  dependencies: {},
@@ -861,6 +886,22 @@ function generatePackageJson(app, appPackageJson, template, layout) {
861
886
  }
862
887
  }
863
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
+
864
905
  return merged;
865
906
  };
866
907
 
@@ -1164,17 +1205,28 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
1164
1205
  sha: template.sha,
1165
1206
  local: template.local,
1166
1207
  manifest: manifest ? MANIFEST_FILE : undefined,
1167
- // Which generator wrote this tree, and the versions it forced
1168
- // while doing it. A deployed app misbehaving is the case this
1169
- // serves: the tree itself then answers which template commit and
1170
- // which SDK it was built from, without a guess from the app's
1171
- // lockfile or from whatever the CLI happens to pin today.
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.
1172
1219
  kit: PACKAGE_VERSION,
1173
1220
  pins: Object.fromEntries(
1174
1221
  Object.values(PINS).flatMap((group) =>
1175
1222
  Object.entries(group).map(([name, pin]) => [name, pin.version]),
1176
1223
  ),
1177
1224
  ),
1225
+ peers: Object.fromEntries(
1226
+ Object.entries(FLOORS).flatMap(([kind, group]) =>
1227
+ Object.keys(group).map((name) => [name, packageJson[kind]?.[name]]),
1228
+ ),
1229
+ ),
1178
1230
  // What survived to the end, copied and generated alike. The
1179
1231
  // copy is the raw list minus whatever a generated file replaced,
1180
1232
  // and the generated half is here so that a build which stops
package/cli/init.mjs CHANGED
@@ -33,6 +33,7 @@ import { basename, dirname, join, relative } from "node:path";
33
33
  import { createInterface } from "node:readline/promises";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import { bold, dim, green, red, yellow } from "./log.mjs";
36
+ import { installable } from "./peers.mjs";
36
37
 
37
38
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
38
39
  const MANIFEST = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"));
@@ -65,36 +66,28 @@ function cleanTitle(input) {
65
66
  .trim();
66
67
  }
67
68
 
68
- /**
69
- * A peer range is a floor, `>=19`, and a floor in an app's dependencies installs
70
- * the next major on the day it lands. Cap it. Anything already ranged, `^4`,
71
- * passes through as it is.
72
- */
73
- function installable(name, range) {
74
- if (!range) {
75
- throw new Error(`@waniwani/kit declares no peer range for ${name}: this package's manifest moved`);
76
- }
77
- const floor = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
78
- return floor ? `^${floor[1]}.${floor[2] ?? 0}.${floor[3] ?? 0}` : range;
79
- }
80
-
81
69
  /**
82
70
  * What a new app depends on.
83
71
  *
84
72
  * Every version is read off this package's own manifest: `@waniwani/kit` at the
85
- * version of the CLI doing the scaffolding, `@waniwani/sdk` at the version this
86
- * runtime is built against, and react, react-dom and zod at the peer ranges this
87
- * package declares. A scaffold that wrote its own numbers here would be the one
88
- * file in the folder that can be wrong the day it is created.
73
+ * version of the CLI doing the scaffolding, and the four peers at the floors
74
+ * this package declares, capped by `installable` so a floor does not install
75
+ * the next major on the day it lands. A scaffold that wrote its own numbers
76
+ * here would be the one file in the folder that can be wrong the day it is
77
+ * created.
78
+ *
79
+ * `@waniwani/sdk` is written out even though a required peer is auto-installed
80
+ * without it, because the app imports it directly — `flows/*.ts` calls
81
+ * `createFlow` — and a package you import belongs in your own manifest rather
82
+ * than arriving because something else asked for it.
89
83
  */
90
84
  function dependencies() {
91
- const peers = MANIFEST.peerDependencies ?? {};
92
85
  return {
93
86
  "@waniwani/kit": `^${MANIFEST.version}`,
94
- "@waniwani/sdk": MANIFEST.dependencies["@waniwani/sdk"],
95
- react: installable("react", peers.react),
96
- "react-dom": installable("react-dom", peers["react-dom"]),
97
- zod: installable("zod", peers.zod),
87
+ "@waniwani/sdk": installable("@waniwani/sdk"),
88
+ react: installable("react"),
89
+ "react-dom": installable("react-dom"),
90
+ zod: installable("zod"),
98
91
  };
99
92
  }
100
93
 
package/cli/peers.mjs ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The peer ranges this package declares, and what to do with them.
3
+ *
4
+ * `@waniwani/sdk`, react, react-dom and zod reach an app as peers rather than
5
+ * as this package's own dependencies, so the app holds one copy and this
6
+ * package states only the floor underneath it. Three callers need that floor
7
+ * and each needs it differently: `init.mjs` writes an installable range into a
8
+ * new app, `validate.mjs` checks the range an existing app already wrote, and
9
+ * `codegen.mjs` fills one in when neither the app nor the template declared
10
+ * one. One module so the parsing exists once.
11
+ *
12
+ * Deliberately not a semver dependency. What appears in a real app's manifest
13
+ * is an exact version, a caret, a tilde or a `>=` floor, and comparing those
14
+ * against a floor is the whole job. Anything this cannot parse is reported as
15
+ * unknown rather than guessed at — see `compare`.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const MANIFEST = JSON.parse(
23
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"),
24
+ );
25
+
26
+ /**
27
+ * A peer range this package declares, read back out so it is stated once.
28
+ *
29
+ * Missing throws. A floor that silently defaulted would let every check below
30
+ * pass vacuously, which is worse than the rename that removed it.
31
+ */
32
+ export function peerRange(name) {
33
+ const range = MANIFEST.peerDependencies?.[name];
34
+ if (!range) {
35
+ throw new Error(
36
+ `@waniwani/kit declares no peerDependencies.${name}, and its own tooling reads that floor — ` +
37
+ "add it back to packages/kit/package.json, or drop the entry that reads it",
38
+ );
39
+ }
40
+ return range;
41
+ }
42
+
43
+ /**
44
+ * A peer range turned into something an app can depend on.
45
+ *
46
+ * A peer range is a floor, `>=19`, and a floor in an app's dependencies
47
+ * installs the next major on the day it lands. Cap it. Anything already ranged,
48
+ * `^4`, passes through as it is.
49
+ *
50
+ * The prerelease tail is part of the pattern because a floor can carry one, and
51
+ * `>=0.19.9-beta.0` falling through uncapped would put the very floor this
52
+ * exists to cap into a new app's manifest. `^0.19.9-beta.0` keeps the
53
+ * prerelease reachable and still stops at `0.20.0`.
54
+ */
55
+ export function installable(name) {
56
+ const range = peerRange(name);
57
+ const floor = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(-[0-9A-Za-z.-]+)?$/.exec(range.trim());
58
+ return floor ? `^${floor[1]}.${floor[2] ?? 0}.${floor[3] ?? 0}${floor[4] ?? ""}` : range;
59
+ }
60
+
61
+ /** `1.2.3-beta.0` → `{ parts: [1,2,3], prerelease: "beta.0" }` */
62
+ function parseVersion(input) {
63
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/.exec(input.trim());
64
+ if (!match) return null;
65
+ return {
66
+ parts: [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)],
67
+ prerelease: match[4],
68
+ };
69
+ }
70
+
71
+ /** Ordering on major.minor.patch, with a prerelease sorting below its release. */
72
+ function order(a, b) {
73
+ for (let i = 0; i < 3; i++) {
74
+ if (a.parts[i] !== b.parts[i]) return a.parts[i] < b.parts[i] ? -1 : 1;
75
+ }
76
+ if (a.prerelease && !b.prerelease) return -1;
77
+ if (!a.prerelease && b.prerelease) return 1;
78
+ return 0;
79
+ }
80
+
81
+ /**
82
+ * The window a dependency spec opens, as `{ low, high }`, where `high` is
83
+ * exclusive and `null` means unbounded.
84
+ *
85
+ * Caret follows semver's 0.x rule, which is the one that matters for the SDK:
86
+ * `^0.19.5` stops at `0.20.0`, so an SDK minor is a breaking change.
87
+ */
88
+ function window(spec) {
89
+ const trimmed = spec.trim();
90
+ if (trimmed === "*" || trimmed === "" || trimmed === "latest") {
91
+ return { low: parseVersion("0.0.0"), high: null };
92
+ }
93
+
94
+ const ranged = /^(\^|~|>=)\s*(.+)$/.exec(trimmed);
95
+ if (!ranged) {
96
+ const exact = parseVersion(trimmed.replace(/^=\s*/, ""));
97
+ return exact ? { low: exact, high: exact, exact: true } : null;
98
+ }
99
+
100
+ const low = parseVersion(ranged[2]);
101
+ if (!low) return null;
102
+ if (ranged[1] === ">=") return { low, high: null };
103
+
104
+ const [major, minor] = low.parts;
105
+ // `~1.2.3` caps at the next minor. `^1.2.3` caps at the next major, except
106
+ // under 0.x where the minor is the compatibility boundary.
107
+ const high =
108
+ ranged[1] === "~" || major === 0
109
+ ? { parts: [major, minor + 1, 0], prerelease: undefined }
110
+ : { parts: [major + 1, 0, 0], prerelease: undefined };
111
+ return { low, high };
112
+ }
113
+
114
+ /**
115
+ * How a dependency spec sits against a peer floor.
116
+ *
117
+ * - `"ok"` — every version the spec allows is at or above the floor.
118
+ * - `"reachable"` — the spec allows the floor, and also allows something below
119
+ * it. A fresh install lands above the floor and a lockfile written earlier
120
+ * can hold the tree below it, so this is a warning rather than an error.
121
+ * - `"below"` — no version the spec allows reaches the floor. This one cannot
122
+ * resolve to a working tree.
123
+ * - `"prerelease"` — a prerelease, at or above the floor by number, which npm's
124
+ * semver rules still exclude from a range carrying no prerelease of its own.
125
+ * Both npm and bun warn on it, so reporting it as `ok` would have this check
126
+ * disagreeing with the tool that actually resolves the tree.
127
+ * - `"unknown"` — an expression this module does not parse (a union, a git
128
+ * URL, `workspace:*`). Reported as-is rather than assumed to be either.
129
+ */
130
+ export function compare(spec, name) {
131
+ const range = peerRange(name);
132
+ const floorMatch = /^>=\s*(.+)$/.exec(range.trim());
133
+ const floor = parseVersion(floorMatch ? floorMatch[1] : range.replace(/^[\^~=]\s*/, ""));
134
+ const allowed = spec == null ? null : window(spec);
135
+ if (!floor || !allowed) return "unknown";
136
+
137
+ // A prerelease is opt-in under semver: `0.19.9-beta.0` does not satisfy
138
+ // `>=0.19.8`, because the range names no prerelease at that version. The
139
+ // floor carrying one of its own is someone pinning a prerelease on purpose,
140
+ // and then the plain comparison is what they asked for.
141
+ if (allowed.low.prerelease && !floor.prerelease) return "prerelease";
142
+ if (order(allowed.low, floor) >= 0) return "ok";
143
+ if (allowed.high === null || order(allowed.high, floor) > 0) return "reachable";
144
+ return "below";
145
+ }
146
+
147
+ /** The floor itself, for a message that has to name it. */
148
+ export function floorOf(name) {
149
+ return peerRange(name);
150
+ }
151
+
152
+ /**
153
+ * The lowest version a spec allows, normalised, or null if unparseable.
154
+ *
155
+ * `scripts/bump-deps.mjs` asks the question this answers, and it is the mirror
156
+ * of `compare`: that one checks a spec against this package's floor, while a
157
+ * floor bump needs to know whether the *template's* floor has risen above it.
158
+ */
159
+ export function floorVersion(spec) {
160
+ const allowed = spec == null ? null : window(spec);
161
+ return allowed?.low ? allowed.low.parts.join(".") : null;
162
+ }
163
+
164
+ /** Ordering on two version strings, for a caller with no window to compare. */
165
+ export function compareVersions(a, b) {
166
+ const left = parseVersion(a);
167
+ const right = parseVersion(b);
168
+ if (!left || !right) return null;
169
+ return order(left, right);
170
+ }
package/cli/validate.mjs CHANGED
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
- import { relative } from "node:path";
10
+ import { join, relative } from "node:path";
11
11
  import { loadAppEnv } from "./env.mjs";
12
+ import { compare, floorOf, installable } from "./peers.mjs";
12
13
 
13
14
  const NAME_RE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/;
14
15
 
@@ -311,6 +312,68 @@ async function load(file, where, report) {
311
312
  }
312
313
  }
313
314
 
315
+ /**
316
+ * The SDK version an app asked for, against the floor this package declares.
317
+ *
318
+ * `@waniwani/sdk` is a required peer (see the manifest's `//sdk` note), so the
319
+ * app owns the version and this is the one place that says what the runtime and
320
+ * the pinned template need underneath it. It reads two manifests off disk and
321
+ * fetches nothing, which is why it runs in `check` rather than waiting for the
322
+ * dependency merge in `codegen.mjs` — a version that cannot work should not
323
+ * need a template download to be told so.
324
+ *
325
+ * Undeclared is not an error. npm and bun both install a required peer, and
326
+ * `codegen.mjs` writes one into the generated project, so an app that never
327
+ * mentions the SDK still gets a working copy.
328
+ */
329
+ function checkPeers(app, report) {
330
+ let manifest;
331
+ try {
332
+ manifest = JSON.parse(readFileSync(join(app.root, "package.json"), "utf-8"));
333
+ } catch {
334
+ // No manifest, or an unparseable one. Both are `init`'s business, and
335
+ // neither is improved by a second error about a dependency inside it.
336
+ return;
337
+ }
338
+
339
+ const name = "@waniwani/sdk";
340
+ const spec = manifest.dependencies?.[name] ?? manifest.devDependencies?.[name];
341
+ if (spec == null) {
342
+ return;
343
+ }
344
+
345
+ const floor = floorOf(name);
346
+ const suggestion = installable(name);
347
+ switch (compare(spec, name)) {
348
+ case "below":
349
+ report.error(
350
+ "package.json",
351
+ `${name} ${spec} cannot reach ${floor}, which this kit needs`,
352
+ `no version that range allows will work: below the floor the SDK declares a @modelcontextprotocol/ext-apps peer that conflicts with the framework's, and npm refuses the tree. Set ${name} to ${suggestion}.`,
353
+ );
354
+ break;
355
+ case "prerelease":
356
+ report.warn(
357
+ "package.json",
358
+ `${name} ${spec} is a prerelease, and ${floor} does not accept one`,
359
+ `npm and bun both exclude a prerelease from a range that names none, so the install warns and the tree may not be what this spec says. Deliberate is fine; ${suggestion} is the released floor.`,
360
+ );
361
+ break;
362
+ case "reachable":
363
+ report.warn(
364
+ "package.json",
365
+ `${name} ${spec} also allows versions below ${floor}`,
366
+ `a fresh install resolves above the floor, and a lockfile written before it moved can hold this tree below it. ${suggestion} says the floor out loud.`,
367
+ );
368
+ break;
369
+ default:
370
+ // "ok", and "unknown" for an expression this cannot parse — a
371
+ // workspace protocol or a git URL, where the version is not in the
372
+ // string and guessing at it would be a false alarm either way.
373
+ break;
374
+ }
375
+ }
376
+
314
377
  export async function validateApp(app) {
315
378
  // The check imports every server-safe module for real, and a module that
316
379
  // builds a client at import time reads the environment while doing it. An app
@@ -319,6 +382,7 @@ export async function validateApp(app) {
319
382
  loadAppEnv(app.root);
320
383
  const report = new Report(app.root);
321
384
  checkStructure(app, report);
385
+ checkPeers(app, report);
322
386
  // Importing broken modules produces noise on top of structural errors.
323
387
  if (report.ok) {
324
388
  await checkModules(app, report);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waniwani/kit",
3
- "version": "0.1.6-beta.0",
3
+ "version": "0.1.6",
4
4
  "description": "Build an MCP app as a folder: tools, widgets and flows, with one CLI and one shared server runtime.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,10 +22,6 @@
22
22
  "default": "./dist/web.js"
23
23
  }
24
24
  },
25
- "//files": "`src` ships alongside `dist` on purpose: `waniwani eject` vendors the runtime as readable TypeScript, and it reads it out of the installed package.",
26
- "//dependencies": "tsx, typescript and the @types packages are here rather than in devDependencies because the underlying framework shells out to tsc and tsx by bare name and resolves types from the app repo tree, while declaring none of them. An app repo owns no build config, so this package is the only thing that can put them there. nodemon is the fourth of that set and arrives on its own, as a peer of it.",
27
- "//express": "express and cors back the api/ convention: the runtime mounts each endpoint with a JSON body parser and CORS, and an app's handlers are typed against express. Both are already skybridge's own dependencies at these ranges, so declaring them here adds no second copy — it stops an app from depending on a transitive hoist.",
28
- "//skybridge": "skybridge and @skybridge/devtools are exact, and the two versions match each other. codegen.mjs forces both on every generated app by reading them back out of this file, so a range here would mean the kit was verified against whatever it resolved while apps were pinned to something else. Bumping them is scripts/bump-deps.mjs, and scripts/template-contract.mjs is what proves the bump.",
29
25
  "files": [
30
26
  "dist",
31
27
  "src",
@@ -49,7 +45,6 @@
49
45
  "@types/react": "^19.2.14",
50
46
  "@types/react-dom": "^19.2.3",
51
47
  "@vitejs/plugin-react": "^6.0.3",
52
- "@waniwani/sdk": "0.19.9-beta.0",
53
48
  "cors": "^2.8.6",
54
49
  "dotenv": "^17.4.1",
55
50
  "express": "^5.2.1",
@@ -63,6 +58,7 @@
63
58
  "@skybridge/devtools": "1.4.0"
64
59
  },
65
60
  "peerDependencies": {
61
+ "@waniwani/sdk": ">=0.19.9-beta.0",
66
62
  "react": ">=19",
67
63
  "react-dom": ">=19",
68
64
  "zod": "^4"