@spectrabrain/eds 0.0.4 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +41 -18
- package/AI_CONTEXT.json +159 -0
- package/CHANGELOG.md +58 -0
- package/CONSUMER_AGENT.md +230 -0
- package/QUICKSTART.md +47 -0
- package/README.md +28 -10
- package/TAILWIND_V4.md +129 -180
- package/TOKEN_CONTEXT.md +71 -0
- package/consumer/cli.mjs +107 -0
- package/consumer/css-root.mjs +239 -0
- package/consumer/doctor.mjs +178 -0
- package/consumer/generate-tailwind.mjs +83 -0
- package/consumer/init.mjs +172 -0
- package/consumer/managed-block.mjs +120 -0
- package/consumer/paths.mjs +211 -0
- package/consumer/register-scripts.mjs +124 -0
- package/consumer/scan-classes.mjs +70 -0
- package/consumer/sync.mjs +133 -0
- package/consumer/update-agent-context.mjs +87 -0
- package/consumer-postinstall.mjs +59 -0
- package/dist/base.css +4 -0
- package/dist/components/Button/README.md +24 -5
- package/dist/components/Button/spec.json +58 -1
- package/dist/components.css +0 -2
- package/dist/index.d.mts +38 -1
- package/dist/index.d.ts +38 -1
- package/dist/index.js +34 -6250
- package/dist/index.mjs +36 -6252
- package/dist/styles.css +1 -0
- package/dist/tokens.d.mts +2 -2
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.js +6 -7
- package/dist/tokens.mjs +6 -7
- package/llms.txt +15 -8
- package/package.json +31 -2
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createExitError, detectPackageManager, packageName, resolvePackage, runnerCommand } from "./paths.mjs";
|
|
4
|
+
import { registerConsumerScripts, syncScriptName, writeConsumerPackageJson } from "./register-scripts.mjs";
|
|
5
|
+
import { runSync } from "./sync.mjs";
|
|
6
|
+
|
|
7
|
+
export const ciTargets = new Set(["github", "gitlab"]);
|
|
8
|
+
const allowScriptsField = "allowScripts";
|
|
9
|
+
const minimumAllowScriptsNpmMajor = 12;
|
|
10
|
+
|
|
11
|
+
const installCommands = {
|
|
12
|
+
npm: "npm ci",
|
|
13
|
+
pnpm: "pnpm install --frozen-lockfile",
|
|
14
|
+
yarn: "yarn install --frozen-lockfile",
|
|
15
|
+
bun: "bun install --frozen-lockfile",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function installCommand(packageManager) {
|
|
19
|
+
if (packageManager.name === "yarn" && packageManager.berry) {
|
|
20
|
+
return "yarn install --immutable";
|
|
21
|
+
}
|
|
22
|
+
return installCommands[packageManager.name];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A CI job that fails when sync is out of date. It runs `npx eds doctor` so the consumer's own
|
|
26
|
+
// package.json scripts are not required.
|
|
27
|
+
export function renderCiSnippet(target, packageManager) {
|
|
28
|
+
const install = installCommand(packageManager);
|
|
29
|
+
if (target === "github") {
|
|
30
|
+
const setup = packageManager.name === "pnpm"
|
|
31
|
+
? [" - uses: pnpm/action-setup@v4"]
|
|
32
|
+
: packageManager.name === "bun"
|
|
33
|
+
? [" - uses: oven-sh/setup-bun@v2"]
|
|
34
|
+
: [];
|
|
35
|
+
const cache = packageManager.name === "bun" ? [] : [" cache: " + packageManager.name];
|
|
36
|
+
return [
|
|
37
|
+
"# .github/workflows/eds-doctor.yml",
|
|
38
|
+
"name: eds doctor",
|
|
39
|
+
"on: [push, pull_request]",
|
|
40
|
+
"jobs:",
|
|
41
|
+
" eds-doctor:",
|
|
42
|
+
" runs-on: ubuntu-latest",
|
|
43
|
+
" steps:",
|
|
44
|
+
" - uses: actions/checkout@v4",
|
|
45
|
+
...setup,
|
|
46
|
+
" - uses: actions/setup-node@v4",
|
|
47
|
+
" with:",
|
|
48
|
+
" node-version: 22",
|
|
49
|
+
...cache,
|
|
50
|
+
" - run: " + install,
|
|
51
|
+
" - run: npx eds doctor",
|
|
52
|
+
"",
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
if (target === "gitlab") {
|
|
56
|
+
const before = packageManager.name === "pnpm"
|
|
57
|
+
? [" - corepack enable"]
|
|
58
|
+
: packageManager.name === "bun"
|
|
59
|
+
? [" - npm install -g bun"]
|
|
60
|
+
: [];
|
|
61
|
+
return [
|
|
62
|
+
"# .gitlab-ci.yml",
|
|
63
|
+
"eds-doctor:",
|
|
64
|
+
" image: node:22",
|
|
65
|
+
" stage: test",
|
|
66
|
+
" script:",
|
|
67
|
+
...before,
|
|
68
|
+
" - " + install,
|
|
69
|
+
" - npx eds doctor",
|
|
70
|
+
"",
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
throw createExitError("Unknown --print-ci target: " + target + " (use github or gitlab).", 2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// npm 12+ blocks dependency install scripts unless package.json allows them. A name-only entry keeps
|
|
77
|
+
// the EDS postinstall running on later installs and upgrades without re-approval.
|
|
78
|
+
function writeAllowScripts(consumerRoot, { log, dryRun }) {
|
|
79
|
+
const packagePath = path.join(consumerRoot, "package.json");
|
|
80
|
+
const source = fs.readFileSync(packagePath, "utf8");
|
|
81
|
+
const consumerPackage = JSON.parse(source);
|
|
82
|
+
const existing = consumerPackage[allowScriptsField];
|
|
83
|
+
if (existing !== undefined && typeof existing !== "object") {
|
|
84
|
+
log("[EDS] package.json " + allowScriptsField + " is not an object; approve the postinstall manually with npm install-scripts approve --no-allow-scripts-pin " + packageName + ".");
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const current = existing?.[packageName];
|
|
88
|
+
if (current !== undefined) {
|
|
89
|
+
log("[EDS] package.json " + allowScriptsField + "[\"" + packageName + "\"] is already " + JSON.stringify(current) + "; left unchanged.");
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (dryRun) {
|
|
93
|
+
log("[EDS] would write " + allowScriptsField + "[\"" + packageName + "\"] = true to package.json");
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
consumerPackage[allowScriptsField] = { ...(existing ?? {}), [packageName]: true };
|
|
97
|
+
writeConsumerPackageJson(packagePath, source, consumerPackage);
|
|
98
|
+
log("[EDS] wrote " + allowScriptsField + "[\"" + packageName + "\"] = true to package.json so npm " + minimumAllowScriptsNpmMajor + "+ runs the EDS postinstall on later installs.");
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun, allowScripts }) {
|
|
103
|
+
const { name, major, berry } = packageManager;
|
|
104
|
+
if (name === "npm") {
|
|
105
|
+
if (major !== null && major >= minimumAllowScriptsNpmMajor) {
|
|
106
|
+
if (allowScripts) {
|
|
107
|
+
writeAllowScripts(consumerRoot, { log, dryRun });
|
|
108
|
+
} else {
|
|
109
|
+
log("[EDS] --no-allow-scripts: not writing " + allowScriptsField + "; later installs skip the EDS postinstall until you run npm install-scripts approve " + packageName + ".");
|
|
110
|
+
}
|
|
111
|
+
} else if (major === null) {
|
|
112
|
+
log("[EDS] npm " + minimumAllowScriptsNpmMajor + "+ blocks dependency install scripts by default; run npm install-scripts approve --no-allow-scripts-pin " + packageName + " there, or rerun init with npm so it can write " + allowScriptsField + ".");
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (name === "pnpm") {
|
|
117
|
+
log("[EDS] pnpm blocks dependency build scripts by default. Run pnpm approve-builds and select " + packageName + ", or add to package.json:");
|
|
118
|
+
log(" \"pnpm\": { \"onlyBuiltDependencies\": [\"" + packageName + "\"] }");
|
|
119
|
+
log("[EDS] Without it, rerun " + runnerCommand(packageManager, syncScriptName) + " (or npx eds init) after each install.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (name === "bun") {
|
|
123
|
+
log("[EDS] bun runs dependency lifecycle scripts only for trustedDependencies. Add to package.json:");
|
|
124
|
+
log(" \"trustedDependencies\": [\"" + packageName + "\"]");
|
|
125
|
+
log("[EDS] Note: setting trustedDependencies replaces bun's default trusted list; include any other package whose install scripts you rely on.");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (name === "yarn" && berry) {
|
|
129
|
+
log("[EDS] Yarn 2+ skips dependency install scripts when enableScripts is false. To run the EDS postinstall, keep enableScripts: true in .yarnrc.yml, or add to package.json:");
|
|
130
|
+
log(" \"dependenciesMeta\": { \"" + packageName + "\": { \"built\": true } }");
|
|
131
|
+
log("[EDS] Otherwise rerun " + runnerCommand(packageManager, syncScriptName) + " (or npx eds init) after each install.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function runInit({
|
|
136
|
+
consumerRoot,
|
|
137
|
+
env = process.env,
|
|
138
|
+
noSync = false,
|
|
139
|
+
dryRun = false,
|
|
140
|
+
allowScripts = true,
|
|
141
|
+
printCi = null,
|
|
142
|
+
log = console.log,
|
|
143
|
+
}) {
|
|
144
|
+
const packageManager = detectPackageManager(consumerRoot, env);
|
|
145
|
+
if (printCi !== null) {
|
|
146
|
+
if (!ciTargets.has(printCi)) {
|
|
147
|
+
throw createExitError("Unknown --print-ci target: " + printCi + " (use github or gitlab).", 2);
|
|
148
|
+
}
|
|
149
|
+
log(renderCiSnippet(printCi, packageManager));
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!fs.existsSync(path.join(consumerRoot, "package.json"))) {
|
|
154
|
+
throw createExitError(
|
|
155
|
+
"No package.json in " + consumerRoot + "; run npx " + packageName + " init from the consumer root.",
|
|
156
|
+
1,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
resolvePackage(consumerRoot);
|
|
161
|
+
} catch {
|
|
162
|
+
throw createExitError(packageName + " is not installed here; run npm install " + packageName + " first.", 1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
registerConsumerScripts(consumerRoot, { log, dryRun, packageManager });
|
|
166
|
+
printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun, allowScripts });
|
|
167
|
+
if (noSync) {
|
|
168
|
+
log("[EDS] Scripts registered. Run " + runnerCommand(packageManager, syncScriptName) + " to finish setup.");
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
return runSync({ consumerRoot, env, dryRun, log });
|
|
172
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { renderRunnerCommands } from "./paths.mjs";
|
|
4
|
+
|
|
5
|
+
export const beginMarker = "<!-- EDS:BEGIN -->";
|
|
6
|
+
export const endMarker = "<!-- EDS:END -->";
|
|
7
|
+
export const versionPattern = /^<!-- EDS:VERSION (\S+) -->$/m;
|
|
8
|
+
export const legacyVersionLabel = "pre-0.1.0";
|
|
9
|
+
|
|
10
|
+
const fenceOpenPattern = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
11
|
+
|
|
12
|
+
export function readTemplateBlock(packageRoot) {
|
|
13
|
+
const guide = fs.readFileSync(path.join(packageRoot, "CONSUMER_AGENT.md"), "utf8");
|
|
14
|
+
const beginIndex = guide.indexOf(beginMarker);
|
|
15
|
+
const endIndex = beginIndex < 0 ? -1 : guide.indexOf(endMarker, beginIndex);
|
|
16
|
+
if (beginIndex < 0 || endIndex < beginIndex) {
|
|
17
|
+
throw new Error("CONSUMER_AGENT.md has an invalid managed block.");
|
|
18
|
+
}
|
|
19
|
+
return guide.slice(beginIndex, endIndex + endMarker.length);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The template is written for npm; the rendered block uses the consumer's package-manager runner
|
|
23
|
+
// (pnpm, yarn, bun run) so the commands an agent reads work in that repository.
|
|
24
|
+
export function renderBlock(template, version, packageManager = null) {
|
|
25
|
+
const versionLine = "<!-- EDS:VERSION " + version + " -->";
|
|
26
|
+
const withoutVersion = template.replace(/^<!-- EDS:VERSION \S+ -->\r?\n/m, "");
|
|
27
|
+
if (!withoutVersion.startsWith(beginMarker)) {
|
|
28
|
+
throw new Error("CONSUMER_AGENT.md managed block must start with " + beginMarker + ".");
|
|
29
|
+
}
|
|
30
|
+
const body = renderRunnerCommands(withoutVersion.slice(beginMarker.length), packageManager);
|
|
31
|
+
return beginMarker + "\n" + versionLine + body;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readBlockVersion(block) {
|
|
35
|
+
return block.match(versionPattern)?.[1] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Replaces the inside of Markdown fenced code blocks (``` or ~~~) with spaces of the same length, so
|
|
39
|
+
// markers quoted as examples are not treated as the real block while every index still maps to the
|
|
40
|
+
// original text. An unclosed fence runs to the end of the file, as in CommonMark.
|
|
41
|
+
export function maskFencedBlocks(text) {
|
|
42
|
+
const lines = text.split(/(\r?\n)/);
|
|
43
|
+
let fence = null;
|
|
44
|
+
const output = [];
|
|
45
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
46
|
+
const segment = lines[index];
|
|
47
|
+
if (index % 2 === 1) {
|
|
48
|
+
output.push(segment);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (fence === null) {
|
|
52
|
+
const opened = segment.match(fenceOpenPattern);
|
|
53
|
+
if (opened) {
|
|
54
|
+
fence = opened[1];
|
|
55
|
+
}
|
|
56
|
+
output.push(segment);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const closing = segment.match(fenceOpenPattern);
|
|
60
|
+
if (closing && closing[1][0] === fence[0] && closing[1].length >= fence.length && segment.trim() === closing[1]) {
|
|
61
|
+
fence = null;
|
|
62
|
+
output.push(segment);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
output.push(" ".repeat(segment.length));
|
|
66
|
+
}
|
|
67
|
+
return output.join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findAll(text, marker) {
|
|
71
|
+
const positions = [];
|
|
72
|
+
let index = text.indexOf(marker);
|
|
73
|
+
while (index >= 0) {
|
|
74
|
+
positions.push(index);
|
|
75
|
+
index = text.indexOf(marker, index + marker.length);
|
|
76
|
+
}
|
|
77
|
+
return positions;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function lineNumberAt(text, index) {
|
|
81
|
+
return text.slice(0, index).split(/\r?\n/).length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function locateBlock(existing) {
|
|
85
|
+
const masked = maskFencedBlocks(existing);
|
|
86
|
+
const begins = findAll(masked, beginMarker);
|
|
87
|
+
const ends = findAll(masked, endMarker);
|
|
88
|
+
if (begins.length !== ends.length || begins.length > 1) {
|
|
89
|
+
const first = begins[0] ?? ends[0];
|
|
90
|
+
throw new Error(
|
|
91
|
+
"AGENTS.md must contain zero or one complete EDS managed block. Found " + begins.length + " " + beginMarker +
|
|
92
|
+
" and " + ends.length + " " + endMarker + " marker" + (begins.length + ends.length === 1 ? "" : "s") +
|
|
93
|
+
" outside fenced code blocks" + (first === undefined ? "" : ", first at line " + lineNumberAt(existing, first)) + ".",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (begins.length === 0) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const [start] = begins;
|
|
101
|
+
const [endIndex] = ends;
|
|
102
|
+
if (endIndex < start) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"AGENTS.md has an invalid EDS managed block: " + endMarker + " at line " + lineNumberAt(existing, endIndex) +
|
|
105
|
+
" comes before " + beginMarker + " at line " + lineNumberAt(existing, start) + ".",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const end = endIndex + endMarker.length;
|
|
109
|
+
return { start, end, block: existing.slice(start, end) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `block` must already use `eol`; the separators around it follow the same line ending.
|
|
113
|
+
export function applyBlock(existing, block, eol = "\n") {
|
|
114
|
+
const located = existing === null ? null : locateBlock(existing);
|
|
115
|
+
if (located) {
|
|
116
|
+
return existing.slice(0, located.start) + block + existing.slice(located.end);
|
|
117
|
+
}
|
|
118
|
+
const trimmed = (existing ?? "").replace(/\s*$/, "");
|
|
119
|
+
return trimmed ? trimmed + eol + eol + block + eol : block + eol;
|
|
120
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
export const packageName = "@spectrabrain/eds";
|
|
6
|
+
export const generatedCssRelativePath = path.join("src", "styles", "eds-tokens.css");
|
|
7
|
+
|
|
8
|
+
const excludedDirectories = new Set([
|
|
9
|
+
"node_modules",
|
|
10
|
+
"dist",
|
|
11
|
+
"build",
|
|
12
|
+
".git",
|
|
13
|
+
"storybook-static",
|
|
14
|
+
"coverage",
|
|
15
|
+
]);
|
|
16
|
+
const cssExtensions = new Set([".css"]);
|
|
17
|
+
const scriptExtensions = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
|
|
18
|
+
|
|
19
|
+
export function createExitError(message, exitCode) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
error.exitCode = exitCode;
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolvePackage(consumerRoot) {
|
|
26
|
+
const require = createRequire(path.join(consumerRoot, "package.json"));
|
|
27
|
+
let packageJsonPath;
|
|
28
|
+
try {
|
|
29
|
+
packageJsonPath = require.resolve(packageName + "/package.json");
|
|
30
|
+
} catch {
|
|
31
|
+
throw createExitError(packageName + " is not installed here (run npm install).", 2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const packageRoot = path.dirname(packageJsonPath);
|
|
35
|
+
let packageJson;
|
|
36
|
+
try {
|
|
37
|
+
packageJson = readJson(packageJsonPath);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw createExitError(packageName + "/package.json could not be read: " + error.message, 2);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
packageRoot,
|
|
43
|
+
packageJson,
|
|
44
|
+
version: String(packageJson.version ?? ""),
|
|
45
|
+
resolve(specifier) {
|
|
46
|
+
return require.resolve(packageName + "/" + specifier);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolvePackageRoot(consumerRoot) {
|
|
52
|
+
return resolvePackage(consumerRoot).packageRoot;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readJson(filePath) {
|
|
56
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readPackageJson(directory) {
|
|
60
|
+
const filePath = path.join(directory, "package.json");
|
|
61
|
+
if (!fs.existsSync(filePath)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return readJson(filePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readTextIfExists(filePath) {
|
|
68
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listFiles(directory, extensions) {
|
|
72
|
+
if (!fs.existsSync(directory)) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const files = [];
|
|
76
|
+
const walk = (current) => {
|
|
77
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
78
|
+
const entryPath = path.join(current, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (excludedDirectories.has(entry.name) || entry.name.startsWith(".")) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
walk(entryPath);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (entry.isFile() && extensions.has(path.extname(entry.name))) {
|
|
87
|
+
files.push(entryPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
walk(directory);
|
|
92
|
+
return files.sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function listCssFiles(consumerRoot) {
|
|
96
|
+
return listFiles(consumerRoot, cssExtensions);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function listScriptFiles(directory) {
|
|
100
|
+
return listFiles(directory, scriptExtensions);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function toPosix(filePath) {
|
|
104
|
+
return filePath.split(path.sep).join("/");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function relativeTo(consumerRoot, filePath) {
|
|
108
|
+
return toPosix(path.relative(consumerRoot, filePath));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function relativeImportPath(fromFile, toFile) {
|
|
112
|
+
const relative = toPosix(path.relative(path.dirname(fromFile), toFile));
|
|
113
|
+
return relative.startsWith(".") ? relative : "./" + relative;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function detectIndent(text) {
|
|
117
|
+
return text.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function detectEol(text) {
|
|
121
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Re-renders LF (or mixed) text with the target file's dominant line ending.
|
|
125
|
+
export function withEol(text, eol) {
|
|
126
|
+
return text.replace(/\r?\n/g, eol);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Comparisons (doctor) treat CRLF and LF as equal so an autocrlf checkout is not reported as stale.
|
|
130
|
+
export function normalizeEol(text) {
|
|
131
|
+
return text === null || text === undefined ? text : text.replace(/\r\n/g, "\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function realpathOrNull(filePath) {
|
|
135
|
+
try {
|
|
136
|
+
return fs.realpathSync(filePath);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const runnerPrefixes = {
|
|
143
|
+
npm: "npm run ",
|
|
144
|
+
pnpm: "pnpm ",
|
|
145
|
+
yarn: "yarn ",
|
|
146
|
+
bun: "bun run ",
|
|
147
|
+
};
|
|
148
|
+
const lockfileManagers = [
|
|
149
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
150
|
+
["yarn.lock", "yarn"],
|
|
151
|
+
["bun.lock", "bun"],
|
|
152
|
+
["bun.lockb", "bun"],
|
|
153
|
+
["package-lock.json", "npm"],
|
|
154
|
+
["npm-shrinkwrap.json", "npm"],
|
|
155
|
+
];
|
|
156
|
+
const htmlExtensions = new Set([".html"]);
|
|
157
|
+
|
|
158
|
+
export function parseUserAgent(userAgent) {
|
|
159
|
+
const match = String(userAgent ?? "").match(/^(npm|pnpm|yarn|bun)\/(\d+)(?:\.(\d+))?(?:\.(\d+))?(\S*)/);
|
|
160
|
+
if (!match) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const [, name, major, minor, patch, rest] = match;
|
|
164
|
+
return { name, version: [major, minor ?? "0", patch ?? "0"].join(".") + rest, major: Number.parseInt(major, 10) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isYarnBerry(consumerRoot, major) {
|
|
168
|
+
if (major !== null) {
|
|
169
|
+
return major >= 2;
|
|
170
|
+
}
|
|
171
|
+
if (fs.existsSync(path.join(consumerRoot, ".yarnrc.yml"))) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const lockfile = readTextIfExists(path.join(consumerRoot, "yarn.lock"));
|
|
175
|
+
return lockfile !== null && /^__metadata:/m.test(lockfile);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function detectPackageManager(consumerRoot, env = process.env) {
|
|
179
|
+
const fromAgent = parseUserAgent(env.npm_config_user_agent);
|
|
180
|
+
let name = fromAgent?.name ?? null;
|
|
181
|
+
let source = fromAgent ? "user-agent" : "lockfile";
|
|
182
|
+
if (name === null) {
|
|
183
|
+
name = lockfileManagers.find(([fileName]) => fs.existsSync(path.join(consumerRoot, fileName)))?.[1] ?? null;
|
|
184
|
+
}
|
|
185
|
+
if (name === null) {
|
|
186
|
+
name = "npm";
|
|
187
|
+
source = "default";
|
|
188
|
+
}
|
|
189
|
+
const major = fromAgent?.major ?? null;
|
|
190
|
+
return {
|
|
191
|
+
name,
|
|
192
|
+
version: fromAgent?.version ?? null,
|
|
193
|
+
major,
|
|
194
|
+
berry: name === "yarn" && isYarnBerry(consumerRoot, major),
|
|
195
|
+
runner: runnerPrefixes[name],
|
|
196
|
+
source,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function runnerCommand(packageManager, scriptName) {
|
|
201
|
+
return (packageManager?.runner ?? runnerPrefixes.npm) + scriptName;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function renderRunnerCommands(text, packageManager) {
|
|
205
|
+
const runner = packageManager?.runner ?? runnerPrefixes.npm;
|
|
206
|
+
return runner === runnerPrefixes.npm ? text : text.replace(/\bnpm run /g, runner);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function listHtmlFiles(consumerRoot) {
|
|
210
|
+
return listFiles(consumerRoot, htmlExtensions);
|
|
211
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectIndent, detectPackageManager, runnerCommand } from "./paths.mjs";
|
|
4
|
+
|
|
5
|
+
export const syncCommand = "eds sync";
|
|
6
|
+
export const doctorCommand = "eds doctor";
|
|
7
|
+
export const syncScriptName = "sync:eds";
|
|
8
|
+
export const doctorScriptName = "eds:doctor";
|
|
9
|
+
|
|
10
|
+
const legacyRunnerCommand = "node --input-type=module -e \"import('@spectrabrain/eds/consumer-sync.mjs')\"";
|
|
11
|
+
const requiredScripts = {
|
|
12
|
+
[syncScriptName]: syncCommand,
|
|
13
|
+
[doctorScriptName]: doctorCommand,
|
|
14
|
+
};
|
|
15
|
+
const legacyScripts = {
|
|
16
|
+
[syncScriptName]: [
|
|
17
|
+
"npm run update:eds-agent-context && npm run generate:eds-tailwind",
|
|
18
|
+
"npm run update:eds-agent-context && npm run generate-eds-tailwind",
|
|
19
|
+
],
|
|
20
|
+
"update:eds-agent-context": [legacyRunnerCommand],
|
|
21
|
+
"generate:eds-tailwind": [legacyRunnerCommand],
|
|
22
|
+
};
|
|
23
|
+
const hookScripts = [
|
|
24
|
+
{ name: "pretypecheck", target: "typecheck" },
|
|
25
|
+
{ name: "prebuild", target: "build" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function writeConsumerPackageJson(packagePath, source, consumerPackage) {
|
|
29
|
+
const trailingNewline = source.endsWith("\n") ? "\n" : "";
|
|
30
|
+
fs.writeFileSync(packagePath, JSON.stringify(consumerPackage, null, detectIndent(source)) + trailingNewline);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A consumer may wrap the EDS command (for example `cross-env EDS_DOCTOR=warn eds doctor`). Any script that
|
|
34
|
+
// still runs the EDS command counts as registered; only scripts that do not run it are reported.
|
|
35
|
+
export function isCompatibleCommand(value, command) {
|
|
36
|
+
if (typeof value !== "string") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40
|
+
return new RegExp("(^|[\\s&|;(])" + escaped + "($|[\\s&|;)])").test(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerConsumerScripts(
|
|
44
|
+
consumerRoot,
|
|
45
|
+
{ log = console.log, dryRun = false, packageManager = detectPackageManager(consumerRoot) } = {},
|
|
46
|
+
) {
|
|
47
|
+
const packagePath = path.join(consumerRoot, "package.json");
|
|
48
|
+
const source = fs.readFileSync(packagePath, "utf8");
|
|
49
|
+
const consumerPackage = JSON.parse(source);
|
|
50
|
+
const scripts = { ...(consumerPackage.scripts ?? {}) };
|
|
51
|
+
const result = { changed: false, added: [], migrated: [], removed: [], skippedHooks: [], conflicts: [] };
|
|
52
|
+
|
|
53
|
+
for (const [name, command] of Object.entries(requiredScripts)) {
|
|
54
|
+
if (isCompatibleCommand(scripts[name], command)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (scripts[name] === undefined) {
|
|
58
|
+
scripts[name] = command;
|
|
59
|
+
result.added.push(name);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (legacyScripts[name]?.includes(scripts[name])) {
|
|
63
|
+
scripts[name] = command;
|
|
64
|
+
result.migrated.push(name);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// Never fail the install over a consumer-owned script: leave it alone and let doctor report it.
|
|
68
|
+
result.conflicts.push(name);
|
|
69
|
+
log(
|
|
70
|
+
`[EVA Design System] Consumer script ${name} already exists with a different command (${JSON.stringify(scripts[name])}); ` +
|
|
71
|
+
`left unchanged. eds doctor reports it until the script runs "${command}".`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const [name, commands] of Object.entries(legacyScripts)) {
|
|
76
|
+
if (requiredScripts[name] === undefined && commands.includes(scripts[name])) {
|
|
77
|
+
delete scripts[name];
|
|
78
|
+
result.removed.push(name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hookTargets = [];
|
|
83
|
+
for (const hook of hookScripts) {
|
|
84
|
+
if (scripts[hook.target] === undefined) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
hookTargets.push(hook.target);
|
|
88
|
+
if (isCompatibleCommand(scripts[hook.name], doctorCommand)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (scripts[hook.name] === undefined) {
|
|
92
|
+
scripts[hook.name] = doctorCommand;
|
|
93
|
+
result.added.push(hook.name);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
result.skippedHooks.push(hook.name);
|
|
97
|
+
log(`[EVA Design System] ${hook.name} is already defined; add "${doctorCommand}" to it manually.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
result.changed = result.added.length > 0 || result.migrated.length > 0 || result.removed.length > 0;
|
|
101
|
+
// When sync:eds belongs to the consumer, point at the CLI instead of their script.
|
|
102
|
+
const syncRunCommand = result.conflicts.includes(syncScriptName) ? "npx eds sync" : runnerCommand(packageManager, syncScriptName);
|
|
103
|
+
if (result.changed && dryRun) {
|
|
104
|
+
log("[EVA Design System] would update package.json scripts: " + [...result.added, ...result.migrated, ...result.removed].join(", "));
|
|
105
|
+
} else if (result.changed) {
|
|
106
|
+
consumerPackage.scripts = scripts;
|
|
107
|
+
writeConsumerPackageJson(packagePath, source, consumerPackage);
|
|
108
|
+
log("[EVA Design System] Added consumer setup scripts. Run " + syncRunCommand + ".");
|
|
109
|
+
} else if (result.conflicts.length === 0) {
|
|
110
|
+
log("[EVA Design System] Consumer setup scripts are already registered. Run " + syncRunCommand + ".");
|
|
111
|
+
} else {
|
|
112
|
+
log("[EVA Design System] Consumer setup scripts were left unchanged. Run " + syncRunCommand + ".");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Yarn berry (2+) does not run pre/post lifecycle hooks, so the doctor gate must be inlined by the consumer.
|
|
116
|
+
if (packageManager?.name === "yarn" && packageManager.berry && hookTargets.length > 0) {
|
|
117
|
+
log(
|
|
118
|
+
"[EVA Design System] Yarn 2+ does not run pretypecheck/prebuild hooks. Prepend \"" + doctorCommand +
|
|
119
|
+
" && \" to your " + hookTargets.join(" and ") + " script" + (hookTargets.length > 1 ? "s" : "") +
|
|
120
|
+
" to keep the doctor gate (this tool does not edit them).",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|