@grampro/kit 2.7.1 → 2.7.3

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 (80) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +233 -207
  2. package/.gbs/skills/gbs-components/references/data-grid.md +9 -0
  3. package/.gbs/skills/gbs-components/references/forms.md +12 -0
  4. package/.gbs/skills/gbs-components/references/install.md +42 -5
  5. package/CHANGELOG.md +963 -798
  6. package/README.md +13 -4
  7. package/index.cjs +250 -138
  8. package/package.json +1 -1
  9. package/source/components/accordion/passport.json +1 -1
  10. package/source/components/alert/passport.json +1 -1
  11. package/source/components/avatar/passport.json +1 -1
  12. package/source/components/badge/passport.json +1 -1
  13. package/source/components/breadcrumb/passport.json +1 -1
  14. package/source/components/button/passport.json +1 -1
  15. package/source/components/card/passport.json +1 -1
  16. package/source/components/checkbox/passport.json +2 -2
  17. package/source/components/checkbox/passport.manual.json +1 -1
  18. package/source/components/checkbox/react/Checkbox.tsx +14 -0
  19. package/source/components/combobox/passport.json +3 -2
  20. package/source/components/combobox/passport.manual.json +1 -1
  21. package/source/components/combobox/react/MultiSelect.tsx +13 -0
  22. package/source/components/combobox/react/Select.tsx +13 -0
  23. package/source/components/combobox/react/surface.ts +70 -0
  24. package/source/components/data-grid/passport.json +6 -6
  25. package/source/components/data-grid/react/DataGrid.tsx +353 -351
  26. package/source/components/date-picker/passport.json +3 -2
  27. package/source/components/date-picker/passport.manual.json +1 -1
  28. package/source/components/date-picker/react/DatePicker.tsx +16 -2
  29. package/source/components/date-picker/react/DateRangePicker.tsx +16 -2
  30. package/source/components/date-picker/react/surface.ts +68 -0
  31. package/source/components/dialog/passport.json +1 -1
  32. package/source/components/file-uploader/passport.json +1 -1
  33. package/source/components/input/passport.json +2 -2
  34. package/source/components/input/passport.manual.json +1 -1
  35. package/source/components/input/react/Input.tsx +21 -0
  36. package/source/components/menu/passport.json +1 -1
  37. package/source/components/modal/passport.json +1 -1
  38. package/source/components/number-input/passport.json +2 -2
  39. package/source/components/number-input/passport.manual.json +1 -1
  40. package/source/components/number-input/react/NumberInput.tsx +30 -0
  41. package/source/components/popover/passport.json +1 -1
  42. package/source/components/progress/passport.json +1 -1
  43. package/source/components/radio-group/passport.json +2 -2
  44. package/source/components/radio-group/passport.manual.json +1 -1
  45. package/source/components/radio-group/react/RadioGroup.tsx +33 -0
  46. package/source/components/shared/core/agent/field-executors.ts +173 -137
  47. package/source/components/shared/core/agent/index.ts +21 -0
  48. package/source/components/shared/core/agent/surface.ts +437 -0
  49. package/source/components/shared/core/agent/webmcp.ts +332 -328
  50. package/source/components/shared/core/agent/write.ts +62 -58
  51. package/source/components/shared/index.ts +7 -0
  52. package/source/components/shared/react/AgentSurface.tsx +145 -0
  53. package/source/components/shared/react/agentSurfaceContext.ts +92 -0
  54. package/source/components/shared/react/useFieldAgent.ts +62 -88
  55. package/source/components/shared/version.json +4 -4
  56. package/source/components/shared/version.ts +6 -6
  57. package/source/components/skeleton/passport.json +1 -1
  58. package/source/components/spinner/passport.json +1 -1
  59. package/source/components/switch/passport.json +2 -2
  60. package/source/components/switch/passport.manual.json +1 -1
  61. package/source/components/switch/react/Switch.tsx +16 -0
  62. package/source/components/tabs/passport.json +1 -1
  63. package/source/components/textarea/passport.json +2 -2
  64. package/source/components/textarea/passport.manual.json +1 -1
  65. package/source/components/textarea/react/Textarea.tsx +16 -0
  66. package/source/components/toaster/passport.json +1 -1
  67. package/source/components/tooltip/passport.json +1 -1
  68. package/tools/doctor/baseline.cjs +228 -0
  69. package/tools/doctor/contract.cjs +252 -0
  70. package/tools/doctor/format.cjs +160 -0
  71. package/tools/doctor/index.cjs +246 -0
  72. package/tools/doctor/usage.cjs +95 -0
  73. package/tools/jsx-tags.cjs +120 -0
  74. package/tools/mcp/server.cjs +60 -41
  75. package/tools/passport/extract.cjs +22 -40
  76. package/tools/passport/hash.cjs +56 -0
  77. package/tools/passport/index.cjs +4 -2
  78. package/tools/passport/typescript.cjs +220 -0
  79. package/tools/prompt.cjs +205 -0
  80. package/tools/ui.cjs +67 -0
@@ -24,6 +24,7 @@ const fs = require("fs");
24
24
  const path = require("path");
25
25
  const { execFileSync } = require("child_process");
26
26
  const { figmaFrame } = require("./figma.cjs");
27
+ const { jsxOpenings } = require("../jsx-tags.cjs");
27
28
 
28
29
  const CATALOGUE = path.join(__dirname, "..", "..", "source", "components");
29
30
  const REFERENCES = path.join(__dirname, "..", "..", ".gbs", "skills", "gbs-components", "references");
@@ -241,43 +242,10 @@ function validateUsage(code) {
241
242
 
242
243
  const issues = [];
243
244
  const used = [];
244
- const opening = /<([A-Z][A-Za-z0-9]*)(?=[\s/>])/g;
245
- let match;
246
-
247
- while ((match = opening.exec(source))) {
248
- const [, name] = match;
245
+ // Shared with `doctor`, which scans whole projects with the same rules.
246
+ for (const { name, attrs } of jsxOpenings(source)) {
249
247
  const entry = known.get(name);
250
248
  if (!entry) continue; // Not ours; the agent may compose with its own components.
251
-
252
- /*
253
- * Scan to the tag's own `>` rather than matching one. An attribute can
254
- * hold an arrow function — `onDismiss={() => {}}` — so the first `>` is
255
- * usually inside a brace, and a regex that stops there finds nothing.
256
- */
257
- let depth = 0;
258
- let quote = null;
259
- let end = -1;
260
- for (let i = match.index + match[0].length; i < source.length; i += 1) {
261
- const ch = source[i];
262
- if (quote) {
263
- if (ch === quote) quote = null;
264
- } else if (ch === '"' || ch === "'" || ch === "`") {
265
- quote = ch;
266
- } else if (ch === "{") {
267
- depth += 1;
268
- } else if (ch === "}") {
269
- depth -= 1;
270
- } else if (ch === ">" && depth === 0) {
271
- end = i;
272
- break;
273
- }
274
- }
275
- if (end === -1) continue; // Unterminated fragment; nothing reliable to say.
276
-
277
- const rawAttrs = source.slice(match.index + match[0].length, end);
278
- const attrs = [...rawAttrs.matchAll(/(?:^|\s)([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s*=|\s|$)/g)].map(
279
- (m) => m[1],
280
- );
281
249
  used.push({ component: name, props: attrs });
282
250
 
283
251
  for (const attr of attrs) {
@@ -340,18 +308,69 @@ function installComponents(names) {
340
308
  }
341
309
 
342
310
  const cli = path.join(__dirname, "..", "..", "index.cjs");
311
+ const withoutColour = (env) => {
312
+ const copy = { ...env };
313
+ delete copy.FORCE_COLOR;
314
+ return copy;
315
+ };
316
+ let stdout = "";
317
+ let stderr = "";
318
+ let failed = false;
343
319
  try {
344
- const output = execFileSync(process.execPath, [cli, "add", ...wanted], {
320
+ stdout = execFileSync(process.execPath, [cli, "add", ...wanted], {
345
321
  cwd: process.cwd(),
346
322
  encoding: "utf8",
347
323
  timeout: 120000,
324
+ // Captured, not forwarded: the installer explains a refusal on stderr,
325
+ // and that explanation is what the agent most needs to read.
326
+ stdio: ["ignore", "pipe", "pipe"],
327
+ // The output below is read by pattern. Colour would break the match, and
328
+ // a host that sets FORCE_COLOR for its own reasons would turn it on.
329
+ env: { ...withoutColour(process.env), NO_COLOR: "1" },
348
330
  });
349
- // The installer prints the one manual step; the agent needs to see it.
350
- const importLine = (output.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
351
- return { installed: wanted, stylesheet: importLine, output: output.trim().slice(-1200) };
352
331
  } catch (error) {
353
- return { error: `Install failed: ${(error.stdout || error.message || "").toString().slice(-600)}` };
332
+ failed = true;
333
+ stdout = (error.stdout || "").toString();
334
+ stderr = (error.stderr || "").toString();
335
+ if (!stdout && !stderr) return { error: `Install failed: ${error.message}` };
354
336
  }
337
+ // Belt and braces: whatever reached the output, match it without escapes.
338
+ const ansi = /\x1b\[[0-9;?]*[A-Za-z]/g;
339
+ stdout = stdout.replace(ansi, "");
340
+ stderr = stderr.replace(ansi, "");
341
+
342
+ // The installer prints the one manual step; the agent needs to see it.
343
+ const importLine = (stdout.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
344
+ const done = [...stdout.matchAll(/✓ Component (\S+) /g)].map((m) => m[1]);
345
+
346
+ /*
347
+ * A component the project has edited is left alone by `add`, and this tool
348
+ * never passes --force. That is deliberate: replacing someone's edited
349
+ * component is their decision, and an agent asked to "install Button" has
350
+ * not been asked to discard the Button they already customised. So the
351
+ * agent is told what was kept, why, and how the user can see the changes —
352
+ * not handed a generic failure it might try to work around.
353
+ */
354
+ const kept = [...stderr.matchAll(/✗ (\S+) was not replaced/g)].map((m) => m[1]);
355
+ if (kept.length) {
356
+ return {
357
+ installed: done,
358
+ kept,
359
+ reason:
360
+ "These components have changes made in this project since they were installed, and " +
361
+ "install_component never overwrites them. They are still in place and still usable.",
362
+ advice:
363
+ "Do not try to replace them. Tell the user, and suggest " +
364
+ `\`npx @grampro/kit doctor ${kept.join(" ")}\`: it shows what was changed locally, what the ` +
365
+ "new version changes, and which call sites an update would break. Replacing them with " +
366
+ "--force discards the user's work, which is their decision to make.",
367
+ detail: stderr.trim().slice(-1500),
368
+ stylesheet: importLine,
369
+ };
370
+ }
371
+
372
+ if (failed) return { error: `Install failed: ${(stderr || stdout).trim().slice(-600)}` };
373
+ return { installed: wanted, stylesheet: importLine, output: stdout.trim().slice(-1200) };
355
374
  }
356
375
 
357
376
  const TOOLS = [
@@ -388,7 +407,7 @@ const TOOLS = [
388
407
  {
389
408
  name: "get_guidance",
390
409
  description:
391
- "Authored guidance on a topic: forms, overlays, data-grid, pickers, styling, accessibility, data-display, toaster, install. Omit the topic to list them.",
410
+ "Authored guidance on a topic: forms (including making a page operable by a browser agent with <AgentSurface>), overlays, data-grid, pickers, styling, accessibility, data-display, toaster, install. Omit the topic to list them.",
392
411
  inputSchema: {
393
412
  type: "object",
394
413
  properties: { topic: { type: "string" } },
@@ -14,9 +14,10 @@
14
14
 
15
15
  const fs = require("fs");
16
16
  const path = require("path");
17
- const crypto = require("crypto");
18
17
  const { allowlistFor } = require("./policy.cjs");
19
18
  const { describable } = require("./merge.cjs");
19
+ const { componentHash } = require("./hash.cjs");
20
+ const { resolveTypeScript } = require("./typescript.cjs");
20
21
 
21
22
  const PASSPORT_VERSION = "1.0.0";
22
23
 
@@ -27,23 +28,17 @@ const PASSPORT_VERSION = "1.0.0";
27
28
  * the runtime dependency story. The caller's project is searched first, so a
28
29
  * consumer regenerating a forked component uses their own TypeScript.
29
30
  */
31
+ /*
32
+ * A TypeScript with the compiler API, on any project: the project's own when
33
+ * it has the API, otherwise the kit's pinned version, fetched once and
34
+ * cached. See typescript.cjs for why, and for how the fetch is kept narrow.
35
+ * Progress goes to stderr so `doctor --json` keeps a clean stdout.
36
+ */
30
37
  function loadTypeScript(searchFrom = []) {
31
- const paths = [...searchFrom, process.cwd(), __dirname].filter(Boolean);
32
- try {
33
- return require(require.resolve("typescript", { paths }));
34
- } catch {
35
- try {
36
- return require("typescript");
37
- } catch {
38
- const error = new Error(
39
- "Passport generation needs the TypeScript compiler API.\n" +
40
- " Install it as a dev dependency: npm i -D typescript\n" +
41
- " It is only needed to generate passports, never to run a component.",
42
- );
43
- error.code = "ETYPESCRIPT_MISSING";
44
- throw error;
45
- }
46
- }
38
+ return resolveTypeScript({
39
+ searchFrom,
40
+ log: (message) => process.stderr.write(`${message}\n`),
41
+ }).ts;
47
42
  }
48
43
 
49
44
  const posix = (p) => p.replace(/\\/g, "/");
@@ -63,26 +58,6 @@ function sourceFiles(dir) {
63
58
  return out.sort();
64
59
  }
65
60
 
66
- /**
67
- * Hash of the inputs the passport is derived from. Deterministic: sorted paths,
68
- * file contents only, no timestamps.
69
- *
70
- * Line endings are normalised first. The same file arrives as CRLF on a
71
- * Windows checkout and LF on CI, so hashing the bytes verbatim made a
72
- * passport generated on one platform read as stale on the other — which it
73
- * did, for all 27 components at once. A content hash should not depend on
74
- * how the file was checked out.
75
- */
76
- function hashSources(files, root) {
77
- const h = crypto.createHash("sha256");
78
- for (const f of [...files].sort()) {
79
- h.update(posix(path.relative(root, f)));
80
- h.update("\0");
81
- h.update(fs.readFileSync(f, "utf8").replace(/\r\n/g, "\n"));
82
- h.update("\0");
83
- }
84
- return `sha256:${h.digest("hex")}`;
85
- }
86
61
 
87
62
  /* ------------------------------------------------------------------ program */
88
63
 
@@ -449,9 +424,17 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
449
424
  exports: componentExports.sort(),
450
425
  },
451
426
  source: {
452
- libraryVersion,
427
+ // A project regenerating its copies stamps each with the kit version it
428
+ // came from, not the project's own version, so `passport --check` can
429
+ // compare like with like.
430
+ libraryVersion: typeof libraryVersion === "function" ? libraryVersion(name, dir) : libraryVersion,
453
431
  files: files.map((f) => posix(path.relative(dir, f))),
454
- sourceHash: hashSources([...files, ...(fs.existsSync(path.join(dir, "styles.css")) ? [path.join(dir, "styles.css")] : [])], repoRoot),
432
+ sourceHash: componentHash(
433
+ name,
434
+ [...files, ...(fs.existsSync(path.join(dir, "styles.css")) ? [path.join(dir, "styles.css")] : [])].map(
435
+ (file) => ({ rel: posix(path.relative(dir, file)), file }),
436
+ ),
437
+ ),
455
438
  },
456
439
  props: propList,
457
440
  ...(externalBases.size
@@ -498,6 +481,5 @@ module.exports = {
498
481
  createProgram,
499
482
  extractComponent,
500
483
  sourceFiles,
501
- hashSources,
502
484
  posix,
503
485
  };
@@ -0,0 +1,56 @@
1
+ /*
2
+ * The content hash a passport records as `source.sourceHash`.
3
+ *
4
+ * Kept on its own, with no TypeScript and no extractor behind it, because two
5
+ * very different callers need the identical function: the passport generator,
6
+ * which writes the hash, and the installer, which has to recompute it inside a
7
+ * user's project to tell an untouched component from an edited one. If those
8
+ * two ever disagreed by a byte, every installed component would read as
9
+ * edited, and `add` would refuse to update any of them.
10
+ *
11
+ * Entries are hashed by label, not by where the file sits on disk. The
12
+ * generator labels a file by its path inside this repository
13
+ * (`source/components/button/react/Button.tsx`); the installer reads the same
14
+ * file from `component-lib/button/react/Button.tsx` and labels it the same
15
+ * way. Sorting by that label — rather than by absolute path, as this used to —
16
+ * also makes the order independent of whether the OS separator is `/` or `\`.
17
+ */
18
+ const fs = require("fs");
19
+ const crypto = require("crypto");
20
+
21
+ /**
22
+ * @param {{ label: string, file: string }[]} entries
23
+ * @returns {string} "sha256:<hex>"
24
+ */
25
+ function hashEntries(entries) {
26
+ const h = crypto.createHash("sha256");
27
+ const sorted = [...entries].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
28
+ for (const { label, file } of sorted) {
29
+ h.update(label);
30
+ h.update("\0");
31
+ // Line endings normalised: the same file is CRLF on a Windows checkout
32
+ // and LF on CI, and a content hash must not depend on how it was checked out.
33
+ h.update(fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n"));
34
+ h.update("\0");
35
+ }
36
+ return `sha256:${h.digest("hex")}`;
37
+ }
38
+
39
+ /**
40
+ * A component's sourceHash, labelled the same way wherever the component sits.
41
+ *
42
+ * Every file is labelled `source/components/<folder>/<path in folder>` — the
43
+ * path it has in this repository. In the repository that is simply where it
44
+ * is; in a project it is installed under `component-lib/` or `src/...`, and
45
+ * labelling it by that location made an untouched installed component hash
46
+ * differently from the passport it shipped with. One rule for both, so the
47
+ * generator, `passport --check` in a project, and `doctor` all agree.
48
+ *
49
+ * @param {string} folder e.g. "date-picker"
50
+ * @param {{ rel: string, file: string }[]} files path inside the folder ("/"-separated), absolute file
51
+ */
52
+ function componentHash(folder, files) {
53
+ return hashEntries(files.map(({ rel, file }) => ({ label: `source/components/${folder}/${rel}`, file })));
54
+ }
55
+
56
+ module.exports = { hashEntries, componentHash };
@@ -149,6 +149,8 @@ function generate(opts) {
149
149
  */
150
150
  function check(opts) {
151
151
  const { results } = buildAll(opts);
152
+ // In this repository it is an npm script; in a project, the CLI.
153
+ const regenerate = opts.regenerate || "npm run passport";
152
154
  const report = [];
153
155
  let errors = 0;
154
156
  let warnings = 0;
@@ -161,14 +163,14 @@ function check(opts) {
161
163
  const problems = [...r.issues];
162
164
  if (actual === null) {
163
165
  problems.unshift({ level: "error", code: "missing-passport", component: r.name,
164
- message: "no passport.json committed; run `npm run passport`" });
166
+ message: `no passport.json committed; run \`${regenerate}\`` });
165
167
  } else if (actual !== expected) {
166
168
  const reason =
167
169
  JSON.parse(actual).source?.sourceHash !== r.effective.source.sourceHash
168
170
  ? "source changed since the passport was generated"
169
171
  : "committed passport differs from generated output";
170
172
  problems.unshift({ level: "error", code: "stale-passport", component: r.name,
171
- message: `passport stale — ${reason}; run \`npm run passport\`` });
173
+ message: `passport stale — ${reason}; run \`${regenerate}\`` });
172
174
  }
173
175
 
174
176
  errors += problems.filter((p) => p.level === "error").length;
@@ -0,0 +1,220 @@
1
+ /*
2
+ * A TypeScript that has the compiler API, whatever the project has installed.
3
+ *
4
+ * Reading a component's contract means resolving types — following
5
+ * `ButtonVariant` to its string literals, expanding
6
+ * `Omit<ButtonHTMLAttributes<…>, "onClick">` into the props it leaves — and
7
+ * that is the type checker's job. The kit has always borrowed the project's
8
+ * TypeScript for it rather than shipping one, so that `npx @grampro/kit add`
9
+ * stays a small download.
10
+ *
11
+ * TypeScript 7 broke the borrowing. It is a native rewrite, its npm package
12
+ * does not expose the JavaScript compiler API (`createProgram` and the rest
13
+ * are absent), and it is what `npm i -D typescript` installs now. So when the
14
+ * project's TypeScript cannot do the job, the exact version the kit itself is
15
+ * built and tested with is fetched once, cached per user, and used from then
16
+ * on. Same extractor, same result, on any TypeScript the project happens to
17
+ * use — or none.
18
+ *
19
+ * Fetching code at run time is a supply-chain surface, so it is narrow:
20
+ * - one exact version, never a range, so what runs is what was tested;
21
+ * - installed by the npm the user already runs `npx` with, so the registry,
22
+ * proxy, mirror and integrity checks are theirs, unchanged;
23
+ * - `--ignore-scripts`, so nothing executes during the install;
24
+ * - written to a temporary folder and moved into place only once complete,
25
+ * so an interrupted download never leaves a half-cache that loads;
26
+ * - checked after install: the version must be the pinned one and the API
27
+ * must be present, or it is not used;
28
+ * - `GRAMPRO_KIT_OFFLINE=1`, or npm's own offline setting, turns it off, and
29
+ * the callers fall back to what needs no TypeScript.
30
+ * Nothing about the project changes: no package.json edit, no node_modules.
31
+ */
32
+ const fs = require("fs");
33
+ const os = require("os");
34
+ const path = require("path");
35
+ const { execFileSync } = require("child_process");
36
+
37
+ /** The kit's own TypeScript. A test fails if this drifts from the devDependency. */
38
+ const PINNED_VERSION = "6.0.3";
39
+
40
+ /** Older names a project may have installed TypeScript 6 under, to sit beside 7. */
41
+ const ALIASES = ["typescript-6", "typescript6"];
42
+
43
+ const hasCompilerApi = (ts) => Boolean(ts && typeof ts.createProgram === "function" && ts.JsxEmit);
44
+
45
+ function tryRequire(name, paths) {
46
+ try {
47
+ return require(require.resolve(name, { paths }));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** The per-user cache, where the operating system expects caches to live. */
54
+ function cacheRoot(env = process.env, platform = process.platform, home = os.homedir()) {
55
+ if (env.GRAMPRO_KIT_CACHE_DIR) return path.resolve(env.GRAMPRO_KIT_CACHE_DIR);
56
+ if (platform === "win32") {
57
+ return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "grampro-kit", "Cache");
58
+ }
59
+ if (platform === "darwin") return path.join(home, "Library", "Caches", "grampro-kit");
60
+ return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "grampro-kit");
61
+ }
62
+
63
+ const cacheDirFor = (version = PINNED_VERSION, env = process.env) =>
64
+ path.join(cacheRoot(env), `typescript-${version}`);
65
+
66
+ /** The pinned TypeScript, if a previous run already fetched it. */
67
+ function fromCache(dir) {
68
+ const pkg = path.join(dir, "node_modules", "typescript", "package.json");
69
+ try {
70
+ if (JSON.parse(fs.readFileSync(pkg, "utf8")).version !== PINNED_VERSION) return null;
71
+ const ts = require(path.join(dir, "node_modules", "typescript"));
72
+ return hasCompilerApi(ts) ? ts : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /*
79
+ * The npm CLI's own entry point, run with the current node — never through a
80
+ * shell. `npm_execpath` is set when this is running under npm or npx; failing
81
+ * that, npm sits next to node in every standard install.
82
+ */
83
+ function findNpmCli(env = process.env, execPath = process.execPath) {
84
+ const candidates = [];
85
+ if (env.npm_execpath) {
86
+ const given = env.npm_execpath;
87
+ candidates.push(given.replace(/npx-cli\.js$/, "npm-cli.js"), given);
88
+ }
89
+ const bin = path.dirname(execPath);
90
+ candidates.push(
91
+ path.join(bin, "node_modules", "npm", "bin", "npm-cli.js"), // Windows
92
+ path.join(bin, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // macOS, Linux
93
+ );
94
+ return candidates.find((file) => /npm-cli\.js$/.test(file) && fs.existsSync(file)) || null;
95
+ }
96
+
97
+ const offline = (env) =>
98
+ env.GRAMPRO_KIT_OFFLINE === "1" || String(env.npm_config_offline).toLowerCase() === "true";
99
+
100
+ /** Fetch the pinned version into the cache. Returns the loaded module, or throws. */
101
+ function download({ env = process.env, log = () => {} } = {}) {
102
+ const npmCli = findNpmCli(env);
103
+ if (!npmCli) {
104
+ const error = new Error("npm was not found next to node, so TypeScript could not be fetched.");
105
+ error.code = "ETYPESCRIPT_FETCH";
106
+ throw error;
107
+ }
108
+
109
+ const dir = cacheDirFor(PINNED_VERSION, env);
110
+ const staging = `${dir}.partial-${process.pid}`;
111
+ fs.rmSync(staging, { recursive: true, force: true });
112
+ fs.mkdirSync(staging, { recursive: true });
113
+ // A minimal manifest so npm treats the folder as its own project, not the user's.
114
+ fs.writeFileSync(path.join(staging, "package.json"), '{"name":"grampro-kit-cache","private":true}\n');
115
+
116
+ log(
117
+ `Fetching TypeScript ${PINNED_VERSION} once, to read component contracts ` +
118
+ `(cached in ${path.dirname(dir)}).`,
119
+ );
120
+ try {
121
+ execFileSync(
122
+ process.execPath,
123
+ [
124
+ npmCli,
125
+ "install",
126
+ `typescript@${PINNED_VERSION}`,
127
+ "--prefix",
128
+ staging,
129
+ "--no-save",
130
+ "--no-package-lock",
131
+ "--ignore-scripts",
132
+ "--no-audit",
133
+ "--no-fund",
134
+ "--loglevel=error",
135
+ ],
136
+ { stdio: ["ignore", "ignore", "pipe"], timeout: 180000, env },
137
+ );
138
+ } catch (cause) {
139
+ fs.rmSync(staging, { recursive: true, force: true });
140
+ const detail = String(cause.stderr || cause.message || "").trim().split("\n").slice(-2).join(" ");
141
+ const error = new Error(`Could not fetch TypeScript ${PINNED_VERSION}: ${detail || "npm failed"}`);
142
+ error.code = "ETYPESCRIPT_FETCH";
143
+ throw error;
144
+ }
145
+
146
+ // Verified before it is trusted, then moved into place in one step.
147
+ const fetched = fromCache(staging);
148
+ if (!fetched) {
149
+ fs.rmSync(staging, { recursive: true, force: true });
150
+ const error = new Error(`The fetched TypeScript was not version ${PINNED_VERSION} with the compiler API.`);
151
+ error.code = "ETYPESCRIPT_FETCH";
152
+ throw error;
153
+ }
154
+ try {
155
+ fs.rmSync(dir, { recursive: true, force: true });
156
+ fs.renameSync(staging, dir);
157
+ } catch {
158
+ // Another run finished first, or the rename is not allowed here; the
159
+ // verified staging copy is still fine to use for this run.
160
+ return fetched;
161
+ }
162
+ return fromCache(dir) || fetched;
163
+ }
164
+
165
+ /**
166
+ * The first TypeScript that has the compiler API: the project's own, a
167
+ * TypeScript 6 the project installed under an alias, the cached copy, or a
168
+ * freshly fetched one.
169
+ *
170
+ * @returns {{ ts: object, source: "project" | "alias" | "cache" | "downloaded" }}
171
+ * @throws ETYPESCRIPT_API when the project's TypeScript lacks the API and
172
+ * fetching is turned off or fails; ETYPESCRIPT_MISSING when there is
173
+ * no TypeScript at all and fetching is off or fails.
174
+ */
175
+ function resolveTypeScript({ searchFrom = [], env = process.env, allowDownload = true, log = () => {} } = {}) {
176
+ const paths = [...searchFrom, process.cwd(), __dirname].filter(Boolean);
177
+
178
+ const project = tryRequire("typescript", paths);
179
+ if (hasCompilerApi(project)) return { ts: project, source: "project" };
180
+ for (const alias of ALIASES) {
181
+ const candidate = tryRequire(alias, paths);
182
+ if (hasCompilerApi(candidate)) return { ts: candidate, source: "alias" };
183
+ }
184
+
185
+ const cached = fromCache(cacheDirFor(PINNED_VERSION, env));
186
+ if (cached) return { ts: cached, source: "cache" };
187
+
188
+ let fetchError = null;
189
+ if (allowDownload && !offline(env)) {
190
+ try {
191
+ return { ts: download({ env, log }), source: "downloaded" };
192
+ } catch (error) {
193
+ fetchError = error;
194
+ }
195
+ }
196
+
197
+ const why = fetchError
198
+ ? ` ${fetchError.message}`
199
+ : offline(env)
200
+ ? " Fetching it is turned off (offline)."
201
+ : "";
202
+ const error = new Error(
203
+ (project
204
+ ? `This project's TypeScript (${project.version || "unknown"}) has no compiler API — TypeScript 7 is a native rewrite without it — and TypeScript ${PINNED_VERSION} could not be used instead.`
205
+ : `Reading component contracts needs TypeScript ${PINNED_VERSION}, which could not be used.`) +
206
+ why +
207
+ ` It is fetched automatically when online; to provide it yourself: npm i -D typescript-6@npm:typescript@${PINNED_VERSION}`,
208
+ );
209
+ error.code = project ? "ETYPESCRIPT_API" : "ETYPESCRIPT_MISSING";
210
+ throw error;
211
+ }
212
+
213
+ module.exports = {
214
+ PINNED_VERSION,
215
+ resolveTypeScript,
216
+ hasCompilerApi,
217
+ cacheRoot,
218
+ cacheDirFor,
219
+ findNpmCli,
220
+ };