@grampro/kit 2.7.2 → 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 (75) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +233 -209
  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 +14 -3
  5. package/CHANGELOG.md +963 -861
  6. package/README.md +11 -3
  7. package/index.cjs +108 -123
  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 +5 -4
  69. package/tools/mcp/server.cjs +13 -1
  70. package/tools/passport/extract.cjs +20 -65
  71. package/tools/passport/hash.cjs +18 -1
  72. package/tools/passport/index.cjs +4 -2
  73. package/tools/passport/typescript.cjs +220 -0
  74. package/tools/prompt.cjs +205 -0
  75. package/tools/ui.cjs +67 -0
@@ -6,7 +6,7 @@
6
6
  "ActionIntent",
7
7
  "Summarization"
8
8
  ],
9
- "$operationsNote": "Mirrors shared/core/agent/field.ts, which is what the runtime dispatches on. source/components/shared/core/agent/__tests__/field-passport.test.ts fails if the two drift. No `apiMethod`: a field is written through its ref — the element for most of these, a handle for Combobox and DatePicker — and that is a normal case, not a gap.",
9
+ "$operationsNote": "Mirrors shared/core/agent/field.ts, which is what the runtime dispatches on. source/components/shared/core/agent/__tests__/field-passport.test.ts fails if the two drift. No `apiMethod`: a field is written through its ref — the element for most of these, a handle for Combobox and DatePicker — and that is a normal case, not a gap. Inside an <AgentSurface> the component registers itself with these operations; NumberInput and RadioGroup are then written through their own commit path rather than the element.",
10
10
  "operations": {
11
11
  "setValue": {
12
12
  "summary": "Write a value into the field, as though a person had entered it.",
@@ -16,6 +16,7 @@ import {
16
16
  } from "react";
17
17
  import { useControllableState } from "../../shared/react/useControllableState";
18
18
  import { describeField } from "../../shared/core/field";
19
+ import { labelText, useAgentSurfaceField } from "../../shared/react/agentSurfaceContext";
19
20
  import { countCharacters } from "../core/count";
20
21
  import { fitHeight } from "../core/size";
21
22
  import type { FieldSize, TextareaLocaleText, TextareaResize } from "../core/types";
@@ -99,6 +100,21 @@ export function Textarea(props: TextareaProps) {
99
100
 
100
101
  // Mirrors the text even when uncontrolled, for the counter and auto-resize.
101
102
  const [current, setCurrent] = useControllableState(valueProp, defaultValue ?? "");
103
+
104
+ // Inside an <AgentSurface>, offered to an agent with what it accepts now.
105
+ useAgentSurfaceField(() => ({
106
+ contract: {
107
+ component: "Textarea",
108
+ kind: "text",
109
+ label: labelText(label, textareaProps["aria-label"], textareaProps.name, textareaProps.placeholder),
110
+ disabled,
111
+ readOnly,
112
+ required,
113
+ maxLength,
114
+ operations: ["setValue", "clear", "focus"],
115
+ },
116
+ target: () => textareaRef.current,
117
+ }));
102
118
  const floor = Math.max(1, minRows ?? rows ?? 3);
103
119
 
104
120
  const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
@@ -11,7 +11,7 @@
11
11
  ]
12
12
  },
13
13
  "source": {
14
- "libraryVersion": "2.7.2",
14
+ "libraryVersion": "2.7.3",
15
15
  "files": [
16
16
  "core/api.ts",
17
17
  "core/index.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.7.2",
11
+ "libraryVersion": "2.7.3",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/types.ts",
@@ -21,7 +21,7 @@
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const crypto = require("crypto");
24
- const { hashEntries } = require("../passport/hash.cjs");
24
+ const { componentHash } = require("../passport/hash.cjs");
25
25
  const { compactContract } = require("./contract.cjs");
26
26
 
27
27
  const MANIFEST = ".install-manifest.json";
@@ -100,12 +100,13 @@ function kitRelativeHash(dir, folder, passport) {
100
100
  if (typeof relative !== "string" || relative.split(/[\\/]/).includes("..")) return null;
101
101
  const file = path.join(dir, relative);
102
102
  if (!fs.existsSync(file)) return null; // a file the passport lists is gone: not pristine
103
- entries.push({ label: `source/components/${folder}/${relative}`, file });
103
+ entries.push({ rel: relative, file });
104
104
  }
105
105
  // The generator hashes styles.css alongside the source when there is one.
106
106
  const styles = path.join(dir, "styles.css");
107
- if (fs.existsSync(styles)) entries.push({ label: `source/components/${folder}/styles.css`, file: styles });
108
- return hashEntries(entries);
107
+ if (fs.existsSync(styles)) entries.push({ rel: "styles.css", file: styles });
108
+ // The generator's own labelling rule, not a copy of it.
109
+ return componentHash(folder, entries);
109
110
  }
110
111
 
111
112
  /**
@@ -308,6 +308,11 @@ function installComponents(names) {
308
308
  }
309
309
 
310
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
+ };
311
316
  let stdout = "";
312
317
  let stderr = "";
313
318
  let failed = false;
@@ -319,6 +324,9 @@ function installComponents(names) {
319
324
  // Captured, not forwarded: the installer explains a refusal on stderr,
320
325
  // and that explanation is what the agent most needs to read.
321
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" },
322
330
  });
323
331
  } catch (error) {
324
332
  failed = true;
@@ -326,6 +334,10 @@ function installComponents(names) {
326
334
  stderr = (error.stderr || "").toString();
327
335
  if (!stdout && !stderr) return { error: `Install failed: ${error.message}` };
328
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, "");
329
341
 
330
342
  // The installer prints the one manual step; the agent needs to see it.
331
343
  const importLine = (stdout.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
@@ -395,7 +407,7 @@ const TOOLS = [
395
407
  {
396
408
  name: "get_guidance",
397
409
  description:
398
- "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.",
399
411
  inputSchema: {
400
412
  type: "object",
401
413
  properties: { topic: { type: "string" } },
@@ -16,7 +16,8 @@ const fs = require("fs");
16
16
  const path = require("path");
17
17
  const { allowlistFor } = require("./policy.cjs");
18
18
  const { describable } = require("./merge.cjs");
19
- const { hashEntries } = require("./hash.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
 
@@ -28,55 +29,16 @@ const PASSPORT_VERSION = "1.0.0";
28
29
  * consumer regenerating a forked component uses their own TypeScript.
29
30
  */
30
31
  /*
31
- * TypeScript 7 is a native rewrite and its npm package does not expose the
32
- * compiler API this generator is built on — `createProgram`, `JsxEmit` and the
33
- * rest are simply absent. It is also what `npm i -D typescript` installs now,
34
- * so a current project has a `typescript` that loads fine and then fails on
35
- * the first enum it is asked for, with an error about `ReactJSX` that says
36
- * nothing useful.
37
- *
38
- * So a candidate is only accepted if it actually has the API. After the
39
- * project's own `typescript`, a TypeScript 6 installed under an alias is
40
- * tried: it sits beside a TypeScript 7 build without replacing it.
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.
41
36
  */
42
- const TS6_ALIASES = ["typescript-6", "typescript6"];
43
- const hasCompilerApi = (ts) => Boolean(ts && typeof ts.createProgram === "function" && ts.JsxEmit);
44
-
45
37
  function loadTypeScript(searchFrom = []) {
46
- const paths = [...searchFrom, process.cwd(), __dirname].filter(Boolean);
47
- const tryLoad = (name) => {
48
- try {
49
- return require(require.resolve(name, { paths }));
50
- } catch {
51
- return null;
52
- }
53
- };
54
-
55
- const primary = tryLoad("typescript");
56
- if (hasCompilerApi(primary)) return primary;
57
- for (const alias of TS6_ALIASES) {
58
- const candidate = tryLoad(alias);
59
- if (hasCompilerApi(candidate)) return candidate;
60
- }
61
-
62
- if (primary) {
63
- const error = new Error(
64
- `This project's TypeScript (${primary.version || "unknown version"}) does not provide the compiler API that ` +
65
- "passports are read with: TypeScript 7 is a native rewrite without it.\n" +
66
- " Install TypeScript 6 alongside it, under another name, so your build keeps using 7:\n" +
67
- " npm i -D typescript-6@npm:typescript@6\n" +
68
- " It is only used to read component contracts, never to build or run a component.",
69
- );
70
- error.code = "ETYPESCRIPT_API";
71
- throw error;
72
- }
73
- const error = new Error(
74
- "Passport generation needs the TypeScript compiler API.\n" +
75
- " Install it as a dev dependency: npm i -D typescript-6@npm:typescript@6\n" +
76
- " It is only needed to read component contracts, never to run a component.",
77
- );
78
- error.code = "ETYPESCRIPT_MISSING";
79
- throw error;
38
+ return resolveTypeScript({
39
+ searchFrom,
40
+ log: (message) => process.stderr.write(`${message}\n`),
41
+ }).ts;
80
42
  }
81
43
 
82
44
  const posix = (p) => p.replace(/\\/g, "/");
@@ -96,20 +58,6 @@ function sourceFiles(dir) {
96
58
  return out.sort();
97
59
  }
98
60
 
99
- /**
100
- * Hash of the inputs the passport is derived from. Deterministic: sorted paths,
101
- * file contents only, no timestamps.
102
- *
103
- * Line endings are normalised first. The same file arrives as CRLF on a
104
- * Windows checkout and LF on CI, so hashing the bytes verbatim made a
105
- * passport generated on one platform read as stale on the other — which it
106
- * did, for all 27 components at once. A content hash should not depend on
107
- * how the file was checked out.
108
- */
109
- function hashSources(files, root) {
110
- // One implementation, shared with the installer: see hash.cjs for why.
111
- return hashEntries(files.map((file) => ({ label: posix(path.relative(root, file)), file })));
112
- }
113
61
 
114
62
  /* ------------------------------------------------------------------ program */
115
63
 
@@ -476,9 +424,17 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
476
424
  exports: componentExports.sort(),
477
425
  },
478
426
  source: {
479
- 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,
480
431
  files: files.map((f) => posix(path.relative(dir, f))),
481
- 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
+ ),
482
438
  },
483
439
  props: propList,
484
440
  ...(externalBases.size
@@ -525,6 +481,5 @@ module.exports = {
525
481
  createProgram,
526
482
  extractComponent,
527
483
  sourceFiles,
528
- hashSources,
529
484
  posix,
530
485
  };
@@ -36,4 +36,21 @@ function hashEntries(entries) {
36
36
  return `sha256:${h.digest("hex")}`;
37
37
  }
38
38
 
39
- module.exports = { hashEntries };
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
+ };