@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.
- package/.gbs/skills/gbs-components/SKILL.md +233 -207
- package/.gbs/skills/gbs-components/references/data-grid.md +9 -0
- package/.gbs/skills/gbs-components/references/forms.md +12 -0
- package/.gbs/skills/gbs-components/references/install.md +42 -5
- package/CHANGELOG.md +963 -798
- package/README.md +13 -4
- package/index.cjs +250 -138
- package/package.json +1 -1
- package/source/components/accordion/passport.json +1 -1
- package/source/components/alert/passport.json +1 -1
- package/source/components/avatar/passport.json +1 -1
- package/source/components/badge/passport.json +1 -1
- package/source/components/breadcrumb/passport.json +1 -1
- package/source/components/button/passport.json +1 -1
- package/source/components/card/passport.json +1 -1
- package/source/components/checkbox/passport.json +2 -2
- package/source/components/checkbox/passport.manual.json +1 -1
- package/source/components/checkbox/react/Checkbox.tsx +14 -0
- package/source/components/combobox/passport.json +3 -2
- package/source/components/combobox/passport.manual.json +1 -1
- package/source/components/combobox/react/MultiSelect.tsx +13 -0
- package/source/components/combobox/react/Select.tsx +13 -0
- package/source/components/combobox/react/surface.ts +70 -0
- package/source/components/data-grid/passport.json +6 -6
- package/source/components/data-grid/react/DataGrid.tsx +353 -351
- package/source/components/date-picker/passport.json +3 -2
- package/source/components/date-picker/passport.manual.json +1 -1
- package/source/components/date-picker/react/DatePicker.tsx +16 -2
- package/source/components/date-picker/react/DateRangePicker.tsx +16 -2
- package/source/components/date-picker/react/surface.ts +68 -0
- package/source/components/dialog/passport.json +1 -1
- package/source/components/file-uploader/passport.json +1 -1
- package/source/components/input/passport.json +2 -2
- package/source/components/input/passport.manual.json +1 -1
- package/source/components/input/react/Input.tsx +21 -0
- package/source/components/menu/passport.json +1 -1
- package/source/components/modal/passport.json +1 -1
- package/source/components/number-input/passport.json +2 -2
- package/source/components/number-input/passport.manual.json +1 -1
- package/source/components/number-input/react/NumberInput.tsx +30 -0
- package/source/components/popover/passport.json +1 -1
- package/source/components/progress/passport.json +1 -1
- package/source/components/radio-group/passport.json +2 -2
- package/source/components/radio-group/passport.manual.json +1 -1
- package/source/components/radio-group/react/RadioGroup.tsx +33 -0
- package/source/components/shared/core/agent/field-executors.ts +173 -137
- package/source/components/shared/core/agent/index.ts +21 -0
- package/source/components/shared/core/agent/surface.ts +437 -0
- package/source/components/shared/core/agent/webmcp.ts +332 -328
- package/source/components/shared/core/agent/write.ts +62 -58
- package/source/components/shared/index.ts +7 -0
- package/source/components/shared/react/AgentSurface.tsx +145 -0
- package/source/components/shared/react/agentSurfaceContext.ts +92 -0
- package/source/components/shared/react/useFieldAgent.ts +62 -88
- package/source/components/shared/version.json +4 -4
- package/source/components/shared/version.ts +6 -6
- package/source/components/skeleton/passport.json +1 -1
- package/source/components/spinner/passport.json +1 -1
- package/source/components/switch/passport.json +2 -2
- package/source/components/switch/passport.manual.json +1 -1
- package/source/components/switch/react/Switch.tsx +16 -0
- package/source/components/tabs/passport.json +1 -1
- package/source/components/textarea/passport.json +2 -2
- package/source/components/textarea/passport.manual.json +1 -1
- package/source/components/textarea/react/Textarea.tsx +16 -0
- package/source/components/toaster/passport.json +1 -1
- package/source/components/tooltip/passport.json +1 -1
- package/tools/doctor/baseline.cjs +228 -0
- package/tools/doctor/contract.cjs +252 -0
- package/tools/doctor/format.cjs +160 -0
- package/tools/doctor/index.cjs +246 -0
- package/tools/doctor/usage.cjs +95 -0
- package/tools/jsx-tags.cjs +120 -0
- package/tools/mcp/server.cjs +60 -41
- package/tools/passport/extract.cjs +22 -40
- package/tools/passport/hash.cjs +56 -0
- package/tools/passport/index.cjs +4 -2
- package/tools/passport/typescript.cjs +220 -0
- package/tools/prompt.cjs +205 -0
- package/tools/ui.cjs +67 -0
package/tools/mcp/server.cjs
CHANGED
|
@@ -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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
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
|
-
|
|
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:
|
|
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 };
|
package/tools/passport/index.cjs
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
+
};
|