@spectrabrain/eds 0.0.4 → 0.1.1

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 (62) hide show
  1. package/AI.md +13 -40
  2. package/AI_CONTEXT.json +90 -0
  3. package/CHANGELOG.md +120 -0
  4. package/CONSUMER_AGENT.md +152 -0
  5. package/FRONTEND_CONVENTIONS.md +252 -0
  6. package/QUICKSTART.md +42 -0
  7. package/README.md +38 -69
  8. package/TAILWIND_V4.md +57 -243
  9. package/TOKEN_CONTEXT.md +98 -0
  10. package/consumer/align/templates/components.json +21 -0
  11. package/consumer/align/templates/env.mock +1 -0
  12. package/consumer/align/templates/eslint.config.js +35 -0
  13. package/consumer/align/templates/package.scripts.json +10 -0
  14. package/consumer/align/templates/prettierignore +2 -0
  15. package/consumer/align/templates/prettierrc.json +7 -0
  16. package/consumer/align/templates/src/app/App.tsx +16 -0
  17. package/consumer/align/templates/src/app/main.tsx +27 -0
  18. package/consumer/align/templates/src/app/providers/ErrorBoundary.tsx +58 -0
  19. package/consumer/align/templates/src/app/providers/ErrorHandler.tsx +29 -0
  20. package/consumer/align/templates/src/app/router/index.tsx +23 -0
  21. package/consumer/align/templates/src/app/styles/index.css +2 -0
  22. package/consumer/align/templates/src/mocks/browser.ts +5 -0
  23. package/consumer/align/templates/src/mocks/handlers.ts +4 -0
  24. package/consumer/align/templates/src/shared/api/index.ts +23 -0
  25. package/consumer/align/templates/src/shared/api/queryClient.ts +24 -0
  26. package/consumer/align/templates/src/shared/lib/eventBus.ts +44 -0
  27. package/consumer/align/templates/src/shared/lib/utils/index.ts +6 -0
  28. package/consumer/align/templates/src/vite-env.d.ts +2 -0
  29. package/consumer/align/templates/tsconfig.json +22 -0
  30. package/consumer/align/templates/vite.config.babel.ts +36 -0
  31. package/consumer/align/templates/vite.config.rolldown.ts +35 -0
  32. package/consumer/align-analysis.mjs +1159 -0
  33. package/consumer/align-plan.mjs +377 -0
  34. package/consumer/align-rules.mjs +473 -0
  35. package/consumer/align.mjs +401 -0
  36. package/consumer/cli.mjs +157 -0
  37. package/consumer/css-root.mjs +259 -0
  38. package/consumer/doctor.mjs +204 -0
  39. package/consumer/generate-tailwind.mjs +83 -0
  40. package/consumer/init.mjs +186 -0
  41. package/consumer/managed-block.mjs +126 -0
  42. package/consumer/paths.mjs +266 -0
  43. package/consumer/register-scripts.mjs +129 -0
  44. package/consumer/scan-classes.mjs +71 -0
  45. package/consumer/sync.mjs +155 -0
  46. package/consumer/update-agent-context.mjs +143 -0
  47. package/consumer-postinstall.mjs +60 -0
  48. package/dist/base.css +4 -0
  49. package/dist/components/Button/README.md +70 -80
  50. package/dist/components/Button/spec.json +71 -133
  51. package/dist/components.css +102 -368
  52. package/dist/index.d.mts +40 -1
  53. package/dist/index.d.ts +40 -1
  54. package/dist/index.js +34 -6250
  55. package/dist/index.mjs +36 -6252
  56. package/dist/styles.css +1 -0
  57. package/dist/tokens.d.mts +5 -2
  58. package/dist/tokens.d.ts +5 -2
  59. package/dist/tokens.js +6 -7
  60. package/dist/tokens.mjs +6 -7
  61. package/llms.txt +22 -9
  62. package/package.json +33 -2
@@ -0,0 +1,186 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { cliCommand, createExitError, detectPackageManager, installCommand, packageName, readPackageJson, 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
+ // --no-allow-scripts-pin keeps the approval valid after EDS upgrades, like the name-only entry init writes.
11
+ const approveCommand = "npm install-scripts approve --no-allow-scripts-pin " + packageName;
12
+
13
+ const ciInstallCommands = {
14
+ npm: "npm ci",
15
+ pnpm: "pnpm install --frozen-lockfile",
16
+ yarn: "yarn install --frozen-lockfile",
17
+ bun: "bun install --frozen-lockfile",
18
+ };
19
+
20
+ function ciInstallCommand(packageManager) {
21
+ if (packageManager.name === "yarn" && packageManager.berry) {
22
+ return "yarn install --immutable";
23
+ }
24
+ return ciInstallCommands[packageManager.name];
25
+ }
26
+
27
+ // A CI job that fails when sync is out of date. It runs the EDS CLI directly with the repository's package
28
+ // manager, so the consumer's own package.json scripts are not required.
29
+ export function renderCiSnippet(target, packageManager) {
30
+ const install = ciInstallCommand(packageManager);
31
+ const doctor = cliCommand(packageManager, "doctor");
32
+ if (target === "github") {
33
+ const setup = packageManager.name === "pnpm"
34
+ ? [" - uses: pnpm/action-setup@v4"]
35
+ : packageManager.name === "bun"
36
+ ? [" - uses: oven-sh/setup-bun@v2"]
37
+ : [];
38
+ const cache = packageManager.name === "bun" ? [] : [" cache: " + packageManager.name];
39
+ return [
40
+ "# .github/workflows/eds-doctor.yml",
41
+ "name: eds doctor",
42
+ "on: [push, pull_request]",
43
+ "jobs:",
44
+ " eds-doctor:",
45
+ " runs-on: ubuntu-latest",
46
+ " steps:",
47
+ " - uses: actions/checkout@v4",
48
+ ...setup,
49
+ " - uses: actions/setup-node@v4",
50
+ " with:",
51
+ " node-version: 22",
52
+ ...cache,
53
+ " - run: " + install,
54
+ " - run: " + doctor,
55
+ "",
56
+ ].join("\n");
57
+ }
58
+ if (target === "gitlab") {
59
+ const before = packageManager.name === "pnpm"
60
+ ? [" - corepack enable"]
61
+ : packageManager.name === "bun"
62
+ ? [" - npm install -g bun"]
63
+ : [];
64
+ return [
65
+ "# .gitlab-ci.yml",
66
+ "eds-doctor:",
67
+ " image: node:22",
68
+ " stage: test",
69
+ " script:",
70
+ ...before,
71
+ " - " + install,
72
+ " - " + doctor,
73
+ "",
74
+ ].join("\n");
75
+ }
76
+ throw createExitError("Unknown --print-ci target: " + target + " (use github or gitlab).", 2);
77
+ }
78
+
79
+ // npm 12+ blocks dependency install scripts unless package.json allows them. A name-only entry keeps
80
+ // the EDS postinstall running on later installs and upgrades without re-approval.
81
+ function writeAllowScripts(consumerRoot, { log, dryRun }) {
82
+ const packagePath = path.join(consumerRoot, "package.json");
83
+ const source = fs.readFileSync(packagePath, "utf8");
84
+ const consumerPackage = JSON.parse(source);
85
+ const existing = consumerPackage[allowScriptsField];
86
+ if (existing !== undefined && typeof existing !== "object") {
87
+ log("[EDS] package.json " + allowScriptsField + " is not an object; left unchanged. To approve the EDS postinstall, run " + approveCommand + ".");
88
+ return false;
89
+ }
90
+ const current = existing?.[packageName];
91
+ if (current !== undefined) {
92
+ log("[EDS] package.json " + allowScriptsField + "[\"" + packageName + "\"] is already " + JSON.stringify(current) + "; left unchanged.");
93
+ return false;
94
+ }
95
+ if (dryRun) {
96
+ log("[EDS] would write " + allowScriptsField + "[\"" + packageName + "\"] = true to package.json");
97
+ return false;
98
+ }
99
+ consumerPackage[allowScriptsField] = { ...(existing ?? {}), [packageName]: true };
100
+ writeConsumerPackageJson(packagePath, source, consumerPackage);
101
+ log("[EDS] wrote " + allowScriptsField + "[\"" + packageName + "\"] = true to package.json so npm " + minimumAllowScriptsNpmMajor + "+ runs the EDS postinstall on later installs.");
102
+ return true;
103
+ }
104
+
105
+ function printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun, allowScripts }) {
106
+ const { name, major, berry } = packageManager;
107
+ if (name === "npm") {
108
+ if (major !== null && major >= minimumAllowScriptsNpmMajor) {
109
+ if (allowScripts) {
110
+ writeAllowScripts(consumerRoot, { log, dryRun });
111
+ } else {
112
+ log("[EDS] --no-allow-scripts: not writing " + allowScriptsField + ". Later installs skip the EDS postinstall until someone runs " + approveCommand + ".");
113
+ }
114
+ } else if (major === null) {
115
+ log("[EDS] The npm version is unknown because init did not run through npm. If this project uses npm " + minimumAllowScriptsNpmMajor +
116
+ "+, run " + approveCommand + ", or rerun " + cliCommand(packageManager, "init") + " so init can write " + allowScriptsField + ".");
117
+ }
118
+ return;
119
+ }
120
+ // The postinstall only re-registers the scripts init has just written, so approving it is optional everywhere
121
+ // except npm 12+, where init records the approval itself.
122
+ if (name === "pnpm") {
123
+ log("[EDS] pnpm: the scripts are registered, so the EDS postinstall needs no build approval. After upgrading EDS, run " +
124
+ runnerCommand(packageManager, syncScriptName) + ".");
125
+ return;
126
+ }
127
+ if (name === "bun") {
128
+ log("[EDS] Optional (bun): to let the EDS postinstall re-register the scripts on later installs, add \"trustedDependencies\": [\"" +
129
+ packageName + "\"] to package.json. It replaces bun's default trusted list, so this is the project owner's decision.");
130
+ return;
131
+ }
132
+ if (name === "yarn" && berry) {
133
+ log("[EDS] Optional (Yarn 2+): to let the EDS postinstall re-register the scripts on later installs, keep enableScripts: true in .yarnrc.yml " +
134
+ "or add \"dependenciesMeta\": { \"" + packageName + "\": { \"built\": true } } to package.json.");
135
+ }
136
+ }
137
+
138
+ export function runInit({
139
+ consumerRoot,
140
+ env = process.env,
141
+ noSync = false,
142
+ dryRun = false,
143
+ allowScripts = true,
144
+ printCi = null,
145
+ log = console.log,
146
+ }) {
147
+ const packageManager = detectPackageManager(consumerRoot, env);
148
+ if (printCi !== null) {
149
+ if (!ciTargets.has(printCi)) {
150
+ throw createExitError("Unknown --print-ci target: " + printCi + " (use github or gitlab).", 2);
151
+ }
152
+ log(renderCiSnippet(printCi, packageManager));
153
+ return 0;
154
+ }
155
+
156
+ if (!fs.existsSync(path.join(consumerRoot, "package.json"))) {
157
+ throw createExitError(
158
+ "No package.json in " + consumerRoot + "; run init in the package that depends on " + packageName + ".",
159
+ 1,
160
+ );
161
+ }
162
+ // A workspace root or a parent folder can resolve a hoisted EDS; the scripts, hooks and AGENTS.md belong to the
163
+ // app package that declares it.
164
+ const consumerPackage = readPackageJson(consumerRoot);
165
+ const declared = ["dependencies", "devDependencies", "optionalDependencies"].some((field) => consumerPackage?.[field]?.[packageName] !== undefined);
166
+ if (!declared) {
167
+ throw createExitError(
168
+ "package.json in " + consumerRoot + " does not declare " + packageName + ". Run init in the app package that uses EDS, after " +
169
+ installCommand(packageManager) + " there.",
170
+ 2,
171
+ );
172
+ }
173
+ try {
174
+ resolvePackage(consumerRoot);
175
+ } catch {
176
+ throw createExitError(packageName + " is declared but not installed; run " + installCommand(packageManager) + " first.", 1);
177
+ }
178
+
179
+ const registered = registerConsumerScripts(consumerRoot, { log, dryRun, packageManager });
180
+ printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun, allowScripts });
181
+ if (noSync) {
182
+ log("[EDS] Run " + registered.syncRunCommand + " to finish setup.");
183
+ return 0;
184
+ }
185
+ return runSync({ consumerRoot, env, dryRun, log });
186
+ }
@@ -0,0 +1,126 @@
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
+ const markerFix = "Keep one " + beginMarker + " line with the " + endMarker + " line after it, delete the other marker lines, " +
85
+ "then run sync again (sync rewrites what is between them).";
86
+
87
+ function describeLines(text, positions) {
88
+ const lines = positions.map((position) => lineNumberAt(text, position));
89
+ return lines.length === 0 ? "none" : (lines.length === 1 ? "line " : "lines ") + lines.join(", ");
90
+ }
91
+
92
+ export function locateBlock(existing) {
93
+ const masked = maskFencedBlocks(existing);
94
+ const begins = findAll(masked, beginMarker);
95
+ const ends = findAll(masked, endMarker);
96
+ if (begins.length !== ends.length || begins.length > 1) {
97
+ throw new Error(
98
+ "AGENTS.md must contain zero or one complete EDS managed block. Outside fenced code blocks it has " + beginMarker + " at " +
99
+ describeLines(existing, begins) + " and " + endMarker + " at " + describeLines(existing, ends) + ". " + markerFix,
100
+ );
101
+ }
102
+ if (begins.length === 0) {
103
+ return null;
104
+ }
105
+
106
+ const [start] = begins;
107
+ const [endIndex] = ends;
108
+ if (endIndex < start) {
109
+ throw new Error(
110
+ "AGENTS.md has an invalid EDS managed block: " + endMarker + " at line " + lineNumberAt(existing, endIndex) +
111
+ " comes before " + beginMarker + " at line " + lineNumberAt(existing, start) + ". " + markerFix,
112
+ );
113
+ }
114
+ const end = endIndex + endMarker.length;
115
+ return { start, end, block: existing.slice(start, end) };
116
+ }
117
+
118
+ // `block` must already use `eol`; the separators around it follow the same line ending.
119
+ export function applyBlock(existing, block, eol = "\n") {
120
+ const located = existing === null ? null : locateBlock(existing);
121
+ if (located) {
122
+ return existing.slice(0, located.start) + block + existing.slice(located.end);
123
+ }
124
+ const trimmed = (existing ?? "").replace(/\s*$/, "");
125
+ return trimmed ? trimmed + eol + eol + block + eol : block + eol;
126
+ }
@@ -0,0 +1,266 @@
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; install it in this package first.", 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
+ // The directory that holds .git at or above startDirectory, or null outside git.
179
+ export function findGitRoot(startDirectory) {
180
+ for (let directory = path.resolve(startDirectory); ; ) {
181
+ if (fs.existsSync(path.join(directory, ".git"))) {
182
+ return directory;
183
+ }
184
+ const parent = path.dirname(directory);
185
+ if (parent === directory) {
186
+ return null;
187
+ }
188
+ directory = parent;
189
+ }
190
+ }
191
+
192
+ // The nearest lockfile names the package manager: this directory, then its parents up to the git root, so a
193
+ // workspace package resolves to the repository lockfile. Outside git only this directory counts, so a stray
194
+ // lockfile in a parent folder is never picked up.
195
+ export function findLockfile(startDirectory) {
196
+ const start = path.resolve(startDirectory);
197
+ const directories = [start];
198
+ for (let directory = start; !fs.existsSync(path.join(directory, ".git")); ) {
199
+ const parent = path.dirname(directory);
200
+ if (parent === directory) {
201
+ directories.length = 1;
202
+ break;
203
+ }
204
+ directories.push(parent);
205
+ directory = parent;
206
+ }
207
+ for (const directory of directories) {
208
+ const found = lockfileManagers.find(([fileName]) => fs.existsSync(path.join(directory, fileName)));
209
+ if (found) {
210
+ return { directory, fileName: found[0], name: found[1] };
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+
216
+ // The lockfile decides; the runner in npm_config_user_agent (npm, npx, pnpm, yarn, bun) decides only without a
217
+ // lockfile. `npx @spectrabrain/eds init` in a pnpm repository therefore still renders pnpm commands. The runner's
218
+ // version is kept only when the runner is that same manager (npm 12+ allowScripts, Yarn 2+).
219
+ export function detectPackageManager(consumerRoot, env = process.env) {
220
+ const fromAgent = parseUserAgent(env.npm_config_user_agent);
221
+ const lockfile = findLockfile(consumerRoot);
222
+ const name = lockfile?.name ?? fromAgent?.name ?? "npm";
223
+ const agent = fromAgent?.name === name ? fromAgent : null;
224
+ const major = agent?.major ?? null;
225
+ return {
226
+ name,
227
+ version: agent?.version ?? null,
228
+ major,
229
+ berry: name === "yarn" && isYarnBerry(lockfile?.directory ?? consumerRoot, major),
230
+ // Yarn Plug'n'Play writes .pnp.cjs (.pnp.js before Yarn 3) next to yarn.lock and installs no node_modules.
231
+ pnp: name === "yarn" && [".pnp.cjs", ".pnp.js"].some((fileName) => fs.existsSync(path.join(lockfile?.directory ?? consumerRoot, fileName))),
232
+ runner: runnerPrefixes[name],
233
+ source: lockfile ? "lockfile" : fromAgent ? "user-agent" : "default",
234
+ };
235
+ }
236
+
237
+ export function runnerCommand(packageManager, scriptName) {
238
+ return (packageManager?.runner ?? runnerPrefixes.npm) + scriptName;
239
+ }
240
+
241
+ // npx and bunx fall back to the registry when no local bin exists, and the unscoped registry name "eds" is an
242
+ // unrelated package, so they always name @spectrabrain/eds. pnpm exec and yarn run only the local bin.
243
+ const cliPrefixes = {
244
+ npm: "npx " + packageName,
245
+ pnpm: "pnpm exec eds",
246
+ yarn: "yarn eds",
247
+ bun: "bunx " + packageName,
248
+ };
249
+ const addPrefixes = { npm: "npm install", pnpm: "pnpm add", yarn: "yarn add", bun: "bun add" };
250
+
251
+ export function cliCommand(packageManager, args) {
252
+ return (cliPrefixes[packageManager?.name] ?? cliPrefixes.npm) + " " + args;
253
+ }
254
+
255
+ export function installCommand(packageManager, names = [packageName]) {
256
+ return (addPrefixes[packageManager?.name] ?? addPrefixes.npm) + " " + names.join(" ");
257
+ }
258
+
259
+ export function renderRunnerCommands(text, packageManager) {
260
+ const runner = packageManager?.runner ?? runnerPrefixes.npm;
261
+ return runner === runnerPrefixes.npm ? text : text.replace(/\bnpm run /g, runner);
262
+ }
263
+
264
+ export function listHtmlFiles(consumerRoot) {
265
+ return listFiles(consumerRoot, htmlExtensions);
266
+ }