@spectrabrain/eds 0.1.0 → 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 (55) hide show
  1. package/AI.md +13 -63
  2. package/AI_CONTEXT.json +26 -95
  3. package/CHANGELOG.md +66 -4
  4. package/CONSUMER_AGENT.md +86 -164
  5. package/FRONTEND_CONVENTIONS.md +252 -0
  6. package/QUICKSTART.md +24 -29
  7. package/README.md +24 -73
  8. package/TAILWIND_V4.md +49 -184
  9. package/TOKEN_CONTEXT.md +63 -36
  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 +90 -40
  37. package/consumer/css-root.mjs +24 -4
  38. package/consumer/doctor.mjs +47 -21
  39. package/consumer/init.mjs +39 -25
  40. package/consumer/managed-block.mjs +11 -5
  41. package/consumer/paths.mjs +69 -14
  42. package/consumer/register-scripts.mjs +17 -12
  43. package/consumer/scan-classes.mjs +4 -3
  44. package/consumer/sync.mjs +35 -13
  45. package/consumer/update-agent-context.mjs +63 -7
  46. package/consumer-postinstall.mjs +8 -7
  47. package/dist/components/Button/README.md +66 -95
  48. package/dist/components/Button/spec.json +19 -138
  49. package/dist/components.css +102 -366
  50. package/dist/index.d.mts +20 -18
  51. package/dist/index.d.ts +20 -18
  52. package/dist/tokens.d.mts +3 -0
  53. package/dist/tokens.d.ts +3 -0
  54. package/llms.txt +20 -14
  55. package/package.json +3 -1
@@ -1,17 +1,21 @@
1
1
  import { findPretendardReferences, planPackageImports, stylesImportLine, fontsImportLine } from "./css-root.mjs";
2
2
  import { readGeneratedVersion } from "./generate-tailwind.mjs";
3
3
  import { legacyVersionLabel } from "./managed-block.mjs";
4
- import { generatedCssRelativePath, normalizeEol, readJson, readPackageJson, runnerCommand, toPosix } from "./paths.mjs";
4
+ import { cliCommand, generatedCssRelativePath, normalizeEol, readJson, readPackageJson, runnerCommand, toPosix } from "./paths.mjs";
5
5
  import { doctorCommand, doctorScriptName, isCompatibleCommand, syncCommand, syncScriptName } from "./register-scripts.mjs";
6
6
  import { scanClassNames } from "./scan-classes.mjs";
7
- import { collectPlans, noTailwindNote } from "./sync.mjs";
7
+ import { collectPlans, describeMissingTailwindRoot } from "./sync.mjs";
8
8
 
9
9
  export const doctorEnvironmentVariable = "EDS_DOCTOR";
10
10
 
11
+ // Without a Tailwind root, sync cannot add these imports, so each line says where they go.
11
12
  const fontGuidance = {
12
- [stylesImportLine]: "missing: @spectrabrain/eds/styles.css import in CSS or JS (or tokens.css + components.css + fonts.css); EDS text styles need the bundled Pretendard font",
13
- [fontsImportLine]: "missing: @spectrabrain/eds/fonts.css import next to tokens.css and components.css; EDS text styles reference the font family \"Pretendard\" from fonts.css",
13
+ [stylesImportLine]: "missing: @spectrabrain/eds/styles.css import (or tokens.css + components.css + fonts.css); add it to the app entry, " +
14
+ "for example src/main.tsx, because sync cannot add it without a Tailwind root",
15
+ [fontsImportLine]: "missing: @spectrabrain/eds/fonts.css import next to tokens.css and components.css; add it where they are imported, " +
16
+ "because EDS text styles use the font family \"Pretendard\" it declares",
14
17
  };
18
+ const syncFixablePattern = /^(?:missing|stale|legacy): /;
15
19
 
16
20
  export function collectFindings({ consumerRoot, env = process.env }) {
17
21
  const plans = collectPlans({ consumerRoot, env });
@@ -22,25 +26,35 @@ export function collectFindings({ consumerRoot, env = process.env }) {
22
26
  let rawColors = null;
23
27
 
24
28
  const scripts = readPackageJson(consumerRoot)?.scripts ?? {};
29
+ const unregistered = [];
25
30
  for (const [name, command] of [[syncScriptName, syncCommand], [doctorScriptName, doctorCommand]]) {
26
31
  if (isCompatibleCommand(scripts[name], command)) {
27
32
  continue;
28
33
  }
34
+ if (scripts[name] === undefined) {
35
+ unregistered.push(name);
36
+ continue;
37
+ }
29
38
  findings.push(
30
- scripts[name] === undefined
31
- ? "scripts: " + name + " is not registered; run npx @spectrabrain/eds init"
32
- : "scripts: " + name + " runs " + JSON.stringify(scripts[name]) + " instead of \"" + command +
33
- "\"; rename that script and run npx @spectrabrain/eds init, or make it run \"" + command + "\"",
39
+ "scripts: " + name + " runs " + JSON.stringify(scripts[name]) + " instead of \"" + command +
40
+ "\"; rename that script and run " + cliCommand(plans.packageManager, "init") + ", or make it run \"" + command + "\"",
34
41
  );
35
42
  }
36
- // The fix line may only point at sync:eds when that script really runs eds sync.
43
+ if (unregistered.length > 0) {
44
+ findings.unshift("scripts: " + unregistered.join(", ") + (unregistered.length === 1 ? " is" : " are") + " not registered; run " +
45
+ cliCommand(plans.packageManager, "init"));
46
+ }
47
+ // The fix lines may only point at sync:eds and eds:doctor when those scripts really run the EDS commands.
37
48
  const syncRegistered = isCompatibleCommand(scripts[syncScriptName], syncCommand);
49
+ const doctorRegistered = isCompatibleCommand(scripts[doctorScriptName], doctorCommand);
50
+ const nonSyncFindings = new Set();
38
51
 
39
52
  if (plans.root === null) {
40
- notes.push(noTailwindNote);
53
+ notes.push(describeMissingTailwindRoot(plans));
41
54
  // Without a Tailwind root, sync cannot insert the imports, so doctor states the requirement directly.
42
55
  for (const line of planPackageImports(plans.sources)) {
43
56
  findings.push(fontGuidance[line]);
57
+ nonSyncFindings.add(fontGuidance[line]);
44
58
  }
45
59
  } else {
46
60
  if (plans.tailwind.before === null) {
@@ -72,13 +86,15 @@ export function collectFindings({ consumerRoot, env = process.env }) {
72
86
  files: [...plans.sources.scripts, ...plans.sources.css, ...plans.sources.html],
73
87
  });
74
88
  for (const hit of classScan.unknown) {
75
- findings.push("unknown: " + hit.location + " " + hit.className + " is not a public EDS class; check tokens.json or " + generatedRelativePath);
89
+ findings.push("unknown: " + hit.location + " " + hit.className + " is not a public EDS class; use a class from " + generatedRelativePath);
76
90
  }
77
91
  rawColors = classScan.raw;
78
92
  }
79
93
 
94
+ notes.push(...plans.deliveryNotes);
80
95
  for (const filePath of findPretendardReferences(consumerRoot, plans.sources)) {
81
- notes.push(filePath + " references Pretendard outside @spectrabrain/eds; EDS text styles use the bundled font, so the extra font source can be removed.");
96
+ notes.push(filePath + " references Pretendard outside @spectrabrain/eds. EDS text styles use the font from @spectrabrain/eds/fonts.css, " +
97
+ "so this does not affect EDS; keep it unless the user asks to remove it, because other styles may use it.");
82
98
  }
83
99
 
84
100
  for (const error of plans.errors) {
@@ -98,13 +114,13 @@ export function collectFindings({ consumerRoot, env = process.env }) {
98
114
  }
99
115
 
100
116
  if (plans.claude && normalizeEol(plans.claude.before) !== normalizeEol(plans.claude.after)) {
101
- findings.push("missing: @AGENTS.md import in CLAUDE.md");
117
+ findings.push("missing: " + plans.claude.importLine + " import in " + plans.claude.relativePath);
102
118
  }
103
119
  if (plans.gemini && normalizeEol(plans.gemini.before) !== normalizeEol(plans.gemini.after)) {
104
- findings.push("missing: @./AGENTS.md import in GEMINI.md");
120
+ findings.push("missing: " + plans.gemini.importLine + " import in " + plans.gemini.relativePath);
105
121
  }
106
122
 
107
- return { plans, findings, notes, installed, generatedRelativePath, syncRegistered, rawColors };
123
+ return { plans, findings, notes, installed, generatedRelativePath, syncRegistered, doctorRegistered, nonSyncFindings, unregistered, rawColors };
108
124
  }
109
125
 
110
126
  const rawColorLineLimit = 40;
@@ -168,11 +184,21 @@ export function runDoctor({ consumerRoot, env = process.env, warn = false, scan
168
184
  for (const finding of result.findings) {
169
185
  log("[EDS] " + finding);
170
186
  }
171
- const fixCommand = result.syncRegistered ? runnerCommand(result.plans.packageManager, syncScriptName) : "npx eds sync";
172
- log(
173
- "[EDS] Run " + fixCommand + " to fix. (" +
174
- doctorEnvironmentVariable + "=warn or eds doctor --warn downgrades this to a warning.)",
175
- );
176
- log("[EDS] Then read the AGENTS.md managed block and QUICKSTART.md in the package root before UI work.");
187
+ // One next command: init when scripts are missing (it also syncs), otherwise sync when it can fix a line.
188
+ // unknown:, invalid:, scripts: conflicts and the imports sync cannot add carry their own fix on the line.
189
+ const packageManager = result.plans.packageManager;
190
+ const syncFixable = result.findings.some((finding) => syncFixablePattern.test(finding) && !result.nonSyncFindings.has(finding));
191
+ if (result.unregistered.length > 0) {
192
+ log("[EDS] Run " + cliCommand(packageManager, "init") + " to register the scripts; it also runs sync.");
193
+ } else if (syncFixable) {
194
+ const syncRun = result.syncRegistered ? runnerCommand(packageManager, syncScriptName) : cliCommand(packageManager, "sync");
195
+ log("[EDS] Run " + syncRun + " to fix the missing:, stale: and legacy: lines.");
196
+ }
197
+ if (downgrade) {
198
+ log("[EDS] Warn mode: the lines above are not fixed, so this exit 0 does not mean the check passed.");
199
+ }
200
+ const doctorRun = result.doctorRegistered ? runnerCommand(packageManager, doctorScriptName) : cliCommand(packageManager, "doctor");
201
+ // The completion line itself starts with "[EDS] synced with"; this line must not contain that text.
202
+ log("[EDS] Then rerun " + doctorRun + " until it reports synced; the QUICKSTART.md completion criteria in the package root list the order.");
177
203
  return downgrade ? 0 : 1;
178
204
  }
package/consumer/init.mjs CHANGED
@@ -1,31 +1,34 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { createExitError, detectPackageManager, packageName, resolvePackage, runnerCommand } from "./paths.mjs";
3
+ import { cliCommand, createExitError, detectPackageManager, installCommand, packageName, readPackageJson, resolvePackage, runnerCommand } from "./paths.mjs";
4
4
  import { registerConsumerScripts, syncScriptName, writeConsumerPackageJson } from "./register-scripts.mjs";
5
5
  import { runSync } from "./sync.mjs";
6
6
 
7
7
  export const ciTargets = new Set(["github", "gitlab"]);
8
8
  const allowScriptsField = "allowScripts";
9
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;
10
12
 
11
- const installCommands = {
13
+ const ciInstallCommands = {
12
14
  npm: "npm ci",
13
15
  pnpm: "pnpm install --frozen-lockfile",
14
16
  yarn: "yarn install --frozen-lockfile",
15
17
  bun: "bun install --frozen-lockfile",
16
18
  };
17
19
 
18
- function installCommand(packageManager) {
20
+ function ciInstallCommand(packageManager) {
19
21
  if (packageManager.name === "yarn" && packageManager.berry) {
20
22
  return "yarn install --immutable";
21
23
  }
22
- return installCommands[packageManager.name];
24
+ return ciInstallCommands[packageManager.name];
23
25
  }
24
26
 
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
+ // 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.
27
29
  export function renderCiSnippet(target, packageManager) {
28
- const install = installCommand(packageManager);
30
+ const install = ciInstallCommand(packageManager);
31
+ const doctor = cliCommand(packageManager, "doctor");
29
32
  if (target === "github") {
30
33
  const setup = packageManager.name === "pnpm"
31
34
  ? [" - uses: pnpm/action-setup@v4"]
@@ -48,7 +51,7 @@ export function renderCiSnippet(target, packageManager) {
48
51
  " node-version: 22",
49
52
  ...cache,
50
53
  " - run: " + install,
51
- " - run: npx eds doctor",
54
+ " - run: " + doctor,
52
55
  "",
53
56
  ].join("\n");
54
57
  }
@@ -66,7 +69,7 @@ export function renderCiSnippet(target, packageManager) {
66
69
  " script:",
67
70
  ...before,
68
71
  " - " + install,
69
- " - npx eds doctor",
72
+ " - " + doctor,
70
73
  "",
71
74
  ].join("\n");
72
75
  }
@@ -81,7 +84,7 @@ function writeAllowScripts(consumerRoot, { log, dryRun }) {
81
84
  const consumerPackage = JSON.parse(source);
82
85
  const existing = consumerPackage[allowScriptsField];
83
86
  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 + ".");
87
+ log("[EDS] package.json " + allowScriptsField + " is not an object; left unchanged. To approve the EDS postinstall, run " + approveCommand + ".");
85
88
  return false;
86
89
  }
87
90
  const current = existing?.[packageName];
@@ -106,29 +109,29 @@ function printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun,
106
109
  if (allowScripts) {
107
110
  writeAllowScripts(consumerRoot, { log, dryRun });
108
111
  } else {
109
- log("[EDS] --no-allow-scripts: not writing " + allowScriptsField + "; later installs skip the EDS postinstall until you run npm install-scripts approve " + packageName + ".");
112
+ log("[EDS] --no-allow-scripts: not writing " + allowScriptsField + ". Later installs skip the EDS postinstall until someone runs " + approveCommand + ".");
110
113
  }
111
114
  } 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 + ".");
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 + ".");
113
117
  }
114
118
  return;
115
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.
116
122
  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.");
123
+ log("[EDS] pnpm: the scripts are registered, so the EDS postinstall needs no build approval. After upgrading EDS, run " +
124
+ runnerCommand(packageManager, syncScriptName) + ".");
120
125
  return;
121
126
  }
122
127
  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.");
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.");
126
130
  return;
127
131
  }
128
132
  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.");
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.");
132
135
  }
133
136
  }
134
137
 
@@ -152,20 +155,31 @@ export function runInit({
152
155
 
153
156
  if (!fs.existsSync(path.join(consumerRoot, "package.json"))) {
154
157
  throw createExitError(
155
- "No package.json in " + consumerRoot + "; run npx " + packageName + " init from the consumer root.",
158
+ "No package.json in " + consumerRoot + "; run init in the package that depends on " + packageName + ".",
156
159
  1,
157
160
  );
158
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
+ }
159
173
  try {
160
174
  resolvePackage(consumerRoot);
161
175
  } catch {
162
- throw createExitError(packageName + " is not installed here; run npm install " + packageName + " first.", 1);
176
+ throw createExitError(packageName + " is declared but not installed; run " + installCommand(packageManager) + " first.", 1);
163
177
  }
164
178
 
165
- registerConsumerScripts(consumerRoot, { log, dryRun, packageManager });
179
+ const registered = registerConsumerScripts(consumerRoot, { log, dryRun, packageManager });
166
180
  printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun, allowScripts });
167
181
  if (noSync) {
168
- log("[EDS] Scripts registered. Run " + runnerCommand(packageManager, syncScriptName) + " to finish setup.");
182
+ log("[EDS] Run " + registered.syncRunCommand + " to finish setup.");
169
183
  return 0;
170
184
  }
171
185
  return runSync({ consumerRoot, env, dryRun, log });
@@ -81,16 +81,22 @@ function lineNumberAt(text, index) {
81
81
  return text.slice(0, index).split(/\r?\n/).length;
82
82
  }
83
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
+
84
92
  export function locateBlock(existing) {
85
93
  const masked = maskFencedBlocks(existing);
86
94
  const begins = findAll(masked, beginMarker);
87
95
  const ends = findAll(masked, endMarker);
88
96
  if (begins.length !== ends.length || begins.length > 1) {
89
- const first = begins[0] ?? ends[0];
90
97
  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)) + ".",
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,
94
100
  );
95
101
  }
96
102
  if (begins.length === 0) {
@@ -102,7 +108,7 @@ export function locateBlock(existing) {
102
108
  if (endIndex < start) {
103
109
  throw new Error(
104
110
  "AGENTS.md has an invalid EDS managed block: " + endMarker + " at line " + lineNumberAt(existing, endIndex) +
105
- " comes before " + beginMarker + " at line " + lineNumberAt(existing, start) + ".",
111
+ " comes before " + beginMarker + " at line " + lineNumberAt(existing, start) + ". " + markerFix,
106
112
  );
107
113
  }
108
114
  const end = endIndex + endMarker.length;
@@ -28,7 +28,7 @@ export function resolvePackage(consumerRoot) {
28
28
  try {
29
29
  packageJsonPath = require.resolve(packageName + "/package.json");
30
30
  } catch {
31
- throw createExitError(packageName + " is not installed here (run npm install).", 2);
31
+ throw createExitError(packageName + " is not installed here; install it in this package first.", 2);
32
32
  }
33
33
 
34
34
  const packageRoot = path.dirname(packageJsonPath);
@@ -175,25 +175,62 @@ function isYarnBerry(consumerRoot, major) {
175
175
  return lockfile !== null && /^__metadata:/m.test(lockfile);
176
176
  }
177
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;
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;
184
206
  }
185
- if (name === null) {
186
- name = "npm";
187
- source = "default";
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
+ }
188
212
  }
189
- const major = fromAgent?.major ?? null;
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;
190
225
  return {
191
226
  name,
192
- version: fromAgent?.version ?? null,
227
+ version: agent?.version ?? null,
193
228
  major,
194
- berry: name === "yarn" && isYarnBerry(consumerRoot, 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))),
195
232
  runner: runnerPrefixes[name],
196
- source,
233
+ source: lockfile ? "lockfile" : fromAgent ? "user-agent" : "default",
197
234
  };
198
235
  }
199
236
 
@@ -201,6 +238,24 @@ export function runnerCommand(packageManager, scriptName) {
201
238
  return (packageManager?.runner ?? runnerPrefixes.npm) + scriptName;
202
239
  }
203
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
+
204
259
  export function renderRunnerCommands(text, packageManager) {
205
260
  const runner = packageManager?.runner ?? runnerPrefixes.npm;
206
261
  return runner === runnerPrefixes.npm ? text : text.replace(/\bnpm run /g, runner);
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { detectIndent, detectPackageManager, runnerCommand } from "./paths.mjs";
3
+ import { cliCommand, detectIndent, detectPackageManager, runnerCommand } from "./paths.mjs";
4
4
 
5
5
  export const syncCommand = "eds sync";
6
6
  export const doctorCommand = "eds doctor";
@@ -48,7 +48,7 @@ export function registerConsumerScripts(
48
48
  const source = fs.readFileSync(packagePath, "utf8");
49
49
  const consumerPackage = JSON.parse(source);
50
50
  const scripts = { ...(consumerPackage.scripts ?? {}) };
51
- const result = { changed: false, added: [], migrated: [], removed: [], skippedHooks: [], conflicts: [] };
51
+ const result = { changed: false, added: [], migrated: [], removed: [], skippedHooks: [], conflicts: [], syncRunCommand: null };
52
52
 
53
53
  for (const [name, command] of Object.entries(requiredScripts)) {
54
54
  if (isCompatibleCommand(scripts[name], command)) {
@@ -67,8 +67,8 @@ export function registerConsumerScripts(
67
67
  // Never fail the install over a consumer-owned script: leave it alone and let doctor report it.
68
68
  result.conflicts.push(name);
69
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}".`,
70
+ `[EDS] package.json script ${name} runs ${JSON.stringify(scripts[name])}; left unchanged. ` +
71
+ `eds doctor reports it until the script runs "${command}".`,
72
72
  );
73
73
  }
74
74
 
@@ -94,28 +94,33 @@ export function registerConsumerScripts(
94
94
  continue;
95
95
  }
96
96
  result.skippedHooks.push(hook.name);
97
- log(`[EVA Design System] ${hook.name} is already defined; add "${doctorCommand}" to it manually.`);
97
+ log(
98
+ `[EDS] ${hook.name} already runs ${JSON.stringify(scripts[hook.name])}; left unchanged. Change it to ` +
99
+ `${JSON.stringify(doctorCommand + " && " + scripts[hook.name])} so ${doctorCommand} gates ${hook.target}.`,
100
+ );
98
101
  }
99
102
 
100
103
  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);
104
+ // When sync:eds belongs to the consumer, point at the CLI instead of their script. The caller prints the next
105
+ // step: init runs sync right away, postinstall tells the consumer to run it.
106
+ result.syncRunCommand = result.conflicts.includes(syncScriptName) ? cliCommand(packageManager, "sync") : runnerCommand(packageManager, syncScriptName);
103
107
  if (result.changed && dryRun) {
104
- log("[EVA Design System] would update package.json scripts: " + [...result.added, ...result.migrated, ...result.removed].join(", "));
108
+ log("[EDS] would update package.json scripts: " + [...result.added, ...result.migrated, ...result.removed].join(", "));
105
109
  } else if (result.changed) {
106
110
  consumerPackage.scripts = scripts;
107
111
  writeConsumerPackageJson(packagePath, source, consumerPackage);
108
- log("[EVA Design System] Added consumer setup scripts. Run " + syncRunCommand + ".");
112
+ log("[EDS] Added the EDS scripts to package.json: " + [...result.added, ...result.migrated].join(", ") +
113
+ (result.removed.length > 0 ? " (removed " + result.removed.join(", ") + ")" : "") + ".");
109
114
  } else if (result.conflicts.length === 0) {
110
- log("[EVA Design System] Consumer setup scripts are already registered. Run " + syncRunCommand + ".");
115
+ log("[EDS] The EDS scripts are already registered.");
111
116
  } else {
112
- log("[EVA Design System] Consumer setup scripts were left unchanged. Run " + syncRunCommand + ".");
117
+ log("[EDS] The EDS scripts were left unchanged.");
113
118
  }
114
119
 
115
120
  // Yarn berry (2+) does not run pre/post lifecycle hooks, so the doctor gate must be inlined by the consumer.
116
121
  if (packageManager?.name === "yarn" && packageManager.berry && hookTargets.length > 0) {
117
122
  log(
118
- "[EVA Design System] Yarn 2+ does not run pretypecheck/prebuild hooks. Prepend \"" + doctorCommand +
123
+ "[EDS] Yarn 2+ does not run pretypecheck/prebuild hooks. Prepend \"" + doctorCommand +
119
124
  " && \" to your " + hookTargets.join(" and ") + " script" + (hookTargets.length > 1 ? "s" : "") +
120
125
  " to keep the doctor gate (this tool does not edit them).",
121
126
  );
@@ -1,14 +1,15 @@
1
1
  import { relativeTo } from "./paths.mjs";
2
2
 
3
- // Tailwind utilities that take a color. A class such as hover:bg-eds-bg-brand-primary/80 is one token here.
3
+ // Tailwind utilities that take a color. A class such as hover:bg-eds-bg-brand-primary/80 is one token here. Token names keep
4
+ // their case (stroke-focusRing), so a miscased class is reported instead of skipped.
4
5
  const colorUtility = "(?:text|bg|border|ring|outline|fill|stroke|decoration|divide|from|to|via|accent|caret|shadow|placeholder|inset-ring)";
5
6
  const variantPrefix = "(?:[a-z0-9-]+:)*";
6
7
  const boundaryBefore = "(?<![\\w-])";
7
8
  const boundaryAfter = "(?![\\w-])";
8
9
  const opacity = "(?:/[0-9]{1,3})?";
9
10
 
10
- const edsColorClass = new RegExp(boundaryBefore + variantPrefix + colorUtility + "-eds-([a-z0-9-]+)" + opacity + boundaryAfter, "g");
11
- const edsTypographyClass = new RegExp(boundaryBefore + variantPrefix + "eds-typography-([a-z0-9-]+)" + boundaryAfter, "g");
11
+ const edsColorClass = new RegExp(boundaryBefore + variantPrefix + colorUtility + "-eds-([A-Za-z0-9-]+)" + opacity + boundaryAfter, "g");
12
+ const edsTypographyClass = new RegExp(boundaryBefore + variantPrefix + "eds-typography-([A-Za-z0-9-]+)" + boundaryAfter, "g");
12
13
  const rawColorClass = new RegExp(
13
14
  boundaryBefore + variantPrefix + colorUtility +
14
15
  "-(?:(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}|white|black|\\[(?:#|rgba?\\(|hsla?\\(|oklch\\(|oklab\\(|color\\()[^\\]]*\\])" +
package/consumer/sync.mjs CHANGED
@@ -3,22 +3,33 @@ import path from "node:path";
3
3
  import {
4
4
  consumerConfigField,
5
5
  findTailwindRoot,
6
+ findTailwindV3File,
6
7
  planCssRootImports,
7
8
  planLegacyJsImportCleanup,
8
9
  readSources,
9
10
  tailwindRootConfigKey,
10
11
  tailwindRootEnvironmentVariable,
12
+ tailwindV3Advice,
11
13
  } from "./css-root.mjs";
12
14
  import { planTailwind } from "./generate-tailwind.mjs";
13
- import { createExitError, detectPackageManager, generatedCssRelativePath, normalizeEol, resolvePackage, toPosix } from "./paths.mjs";
14
- import { writeConsumerPackageJson } from "./register-scripts.mjs";
15
- import { planAgentContext, planClaudeImport, planGeminiImport } from "./update-agent-context.mjs";
15
+ import { cliCommand, createExitError, detectPackageManager, generatedCssRelativePath, normalizeEol, readPackageJson, resolvePackage, runnerCommand, toPosix } from "./paths.mjs";
16
+ import { doctorCommand, doctorScriptName, isCompatibleCommand, writeConsumerPackageJson } from "./register-scripts.mjs";
17
+ import { planAgentContext, planClaudeImport, planDeliveryNotes, planGeminiImport } from "./update-agent-context.mjs";
16
18
 
17
19
  export const syncTargets = new Set(["tailwind", "agents"]);
20
+ const skippedWiring = "skipped " + toPosix(generatedCssRelativePath) + " and the CSS imports.";
18
21
  export const noTailwindNote =
19
- "No Tailwind root CSS found (no @import \"tailwindcss\"); skipped " + toPosix(generatedCssRelativePath) +
20
- " and the CSS imports. Set " + tailwindRootEnvironmentVariable + "=<file> once to wire Tailwind; sync records it in package.json " +
21
- consumerConfigField + "." + tailwindRootConfigKey + ".";
22
+ "No Tailwind root CSS found (no @import \"tailwindcss\"); " + skippedWiring + " Without Tailwind, import @spectrabrain/eds/styles.css " +
23
+ "in the app entry. To use Tailwind CSS v4, add @import \"tailwindcss\"; to the app's global CSS and run sync again; set " +
24
+ tailwindRootEnvironmentVariable + "=<file> once only when that file is not detected (sync records it in package.json " +
25
+ consumerConfigField + "." + tailwindRootConfigKey + ").";
26
+
27
+ // The line sync and doctor print when there is no Tailwind root: a Tailwind CSS v3 repository gets its own advice.
28
+ export function describeMissingTailwindRoot(plans) {
29
+ return plans.tailwindV3File === null
30
+ ? noTailwindNote
31
+ : "Tailwind CSS v3 detected (@tailwind directives in " + plans.tailwindV3File + "); " + skippedWiring + " " + tailwindV3Advice;
32
+ }
22
33
 
23
34
  export function collectPlans({ consumerRoot, env = process.env }) {
24
35
  const pkg = resolvePackage(consumerRoot);
@@ -29,6 +40,7 @@ export function collectPlans({ consumerRoot, env = process.env }) {
29
40
  const tailwind = root === null ? null : planTailwind({ consumerRoot, pkg });
30
41
  const cssRoot = root === null ? null : planCssRootImports({ consumerRoot, root, sources });
31
42
  const legacyImports = root === null ? [] : planLegacyJsImportCleanup({ consumerRoot });
43
+ const tailwindV3File = root === null ? findTailwindV3File(consumerRoot, sources) : null;
32
44
  const errors = [];
33
45
  let agents = null;
34
46
  try {
@@ -38,7 +50,8 @@ export function collectPlans({ consumerRoot, env = process.env }) {
38
50
  }
39
51
  const claude = planClaudeImport({ consumerRoot });
40
52
  const gemini = planGeminiImport({ consumerRoot });
41
- return { consumerRoot, pkg, packageManager, sources, root, tailwind, cssRoot, legacyImports, agents, claude, gemini, errors };
53
+ const deliveryNotes = planDeliveryNotes({ consumerRoot, agents, packageManager });
54
+ return { consumerRoot, pkg, packageManager, sources, root, tailwindV3File, tailwind, cssRoot, legacyImports, agents, claude, gemini, deliveryNotes, errors };
42
55
  }
43
56
 
44
57
  export function planSync({ consumerRoot, env = process.env, only = null }) {
@@ -47,6 +60,8 @@ export function planSync({ consumerRoot, env = process.env, only = null }) {
47
60
  }
48
61
  const plans = collectPlans({ consumerRoot, env });
49
62
  if (plans.errors.length > 0) {
63
+ // Same wording as the doctor line, so the CONSUMER_AGENT.md table applies to both.
64
+ plans.errors[0].message = "invalid: " + plans.errors[0].message;
50
65
  throw plans.errors[0];
51
66
  }
52
67
 
@@ -78,7 +93,9 @@ function persistTailwindRoot(plan, { dryRun, log }) {
78
93
  const consumerPackage = JSON.parse(source);
79
94
  const existing = consumerPackage[consumerConfigField];
80
95
  if (existing !== undefined && (typeof existing !== "object" || existing === null || Array.isArray(existing))) {
81
- log("[EDS] package.json " + consumerConfigField + " is not an object; " + tailwindRootEnvironmentVariable + " was not recorded there.");
96
+ log("[EDS] package.json \"" + consumerConfigField + "\" is not an object, so " + tailwindRootEnvironmentVariable + " was not recorded. " +
97
+ "With several Tailwind CSS candidates, doctor and the pretypecheck/prebuild hooks exit 2 again. Check what uses the existing \"" +
98
+ consumerConfigField + "\" value and move it so \"" + consumerConfigField + "\" can be an object, or set " + tailwindRootEnvironmentVariable + " on every run.");
82
99
  return false;
83
100
  }
84
101
  const value = plan.root.relativePath;
@@ -99,11 +116,14 @@ function persistTailwindRoot(plan, { dryRun, log }) {
99
116
  export function applySync(plan, { dryRun = false, log = console.log } = {}) {
100
117
  const version = "@spectrabrain/eds@" + plan.pkg.version;
101
118
  if (plan.root === null && plan.only !== "agents") {
102
- log("[EDS] " + noTailwindNote);
119
+ log("[EDS] " + describeMissingTailwindRoot(plan));
103
120
  } else if (plan.root?.warning) {
104
121
  log("[EDS] " + plan.root.warning);
105
122
  }
106
123
  persistTailwindRoot(plan, { dryRun, log });
124
+ for (const note of plan.deliveryNotes) {
125
+ log("[EDS] note: " + note);
126
+ }
107
127
  if (plan.writes.length === 0) {
108
128
  log("[EDS] Already synced with " + version + ".");
109
129
  return [];
@@ -118,10 +138,12 @@ export function applySync(plan, { dryRun = false, log = console.log } = {}) {
118
138
  log("[EDS] wrote " + write.relativePath);
119
139
  }
120
140
  if (!dryRun) {
121
- log(
122
- "[EDS] Synced with " + version + ". Review the diff (AGENTS.md, CSS imports, " + toPosix(generatedCssRelativePath) +
123
- ") before committing; the AGENTS.md block lists the rules.",
124
- );
141
+ // Like doctor's footer: the eds:doctor script only when it runs eds doctor, the CLI otherwise.
142
+ const scripts = readPackageJson(plan.consumerRoot)?.scripts ?? {};
143
+ const doctorRun = isCompatibleCommand(scripts[doctorScriptName], doctorCommand)
144
+ ? runnerCommand(plan.packageManager, doctorScriptName)
145
+ : cliCommand(plan.packageManager, "doctor");
146
+ log("[EDS] Synced with " + version + ". Include the files written above in your change, then run " + doctorRun + ".");
125
147
  }
126
148
  return plan.writes.map((write) => write.relativePath);
127
149
  }