@spectrabrain/eds 0.3.1 → 0.6.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.
@@ -149,7 +149,7 @@ export function findTailwindRoot(
149
149
  "Could not identify one Tailwind root CSS file (found " + found.length + ": " + found.join(", ") + "). " +
150
150
  "Ask the user which of these is the app's Tailwind root, then run " + tailwindRootEnvironmentVariable + "=<that file> " +
151
151
  runnerCommand(packageManager, "sync:eds") + " once. sync records the choice in package.json as \"" + consumerConfigField +
152
- "\": { \"" + tailwindRootConfigKey + "\": \"<that file>\" } so doctor and the hooks reuse it.",
152
+ "\": { \"" + tailwindRootConfigKey + "\": \"<that file>\" } so doctor and sync reuse it.",
153
153
  2,
154
154
  );
155
155
  }
@@ -11,10 +11,6 @@ const propertyNames = [
11
11
 
12
12
  export const fontSansStyleName = "body1";
13
13
 
14
- function safeTailwindAlias(name) {
15
- return "eds-" + name.replace(/\./g, "-");
16
- }
17
-
18
14
  function cssVariableForToken(manifest, name) {
19
15
  const cssVariable = manifest.tokens?.[name]?.$extensions?.eds?.cssVariable;
20
16
  if (typeof cssVariable === "string") {
@@ -24,27 +20,9 @@ function cssVariableForToken(manifest, name) {
24
20
  return "--eds-" + name.replace(/\./g, "\\.");
25
21
  }
26
22
 
27
- function collectDimensionAliases(manifest, prefix) {
28
- const aliases = new Map();
29
-
30
- for (const name of Object.keys(manifest.tokens ?? {}).filter((entry) => entry.startsWith(prefix))) {
31
- const alias = safeTailwindAlias(name);
32
- const existing = aliases.get(alias);
33
- if (existing !== undefined && existing !== name) {
34
- throw new Error(`Tailwind alias collision: ${existing} and ${name} both map to ${alias}.`);
35
- }
36
-
37
- aliases.set(alias, name);
38
- }
39
-
40
- return [...aliases.entries()].sort(([left], [right]) => left.localeCompare(right));
41
- }
42
-
43
23
  export function renderTailwindCss(manifest, version) {
44
24
  const tokens = Object.keys(manifest.tokens ?? {});
45
25
  const colorNames = tokens.filter((name) => name.startsWith("color-"));
46
- const spacingAliases = collectDimensionAliases(manifest, "spacing-");
47
- const radiusAliases = collectDimensionAliases(manifest, "radius-");
48
26
  const typography = new Map();
49
27
 
50
28
  for (const name of tokens.filter((entry) => entry.startsWith("typography-"))) {
@@ -74,12 +52,6 @@ export function renderTailwindCss(manifest, version) {
74
52
  const colorAliases = colorNames
75
53
  .map((name) => ` --color-eds-${name.slice("color-".length)}: var(${cssVariableForToken(manifest, name)});`)
76
54
  .join("\n");
77
- const spacingThemeAliases = spacingAliases
78
- .map(([alias, name]) => ` --spacing-${alias}: var(${cssVariableForToken(manifest, name)});`)
79
- .join("\n");
80
- const radiusThemeAliases = radiusAliases
81
- .map(([alias, name]) => ` --radius-${alias}: var(${cssVariableForToken(manifest, name)});`)
82
- .join("\n");
83
55
  // Last line of the @theme block: Tailwind's default font family becomes the EDS body font.
84
56
  const fontSans = ` --font-sans: var(--eds-typography-${fontSansStyleName}-fontFamily);`;
85
57
  const typographyUtilities = [...typography.keys()].sort().map((styleName) => [
@@ -96,8 +68,6 @@ export function renderTailwindCss(manifest, version) {
96
68
  `/* Generated from @spectrabrain/eds@${version}. Do not edit manually. */`,
97
69
  "@theme inline {",
98
70
  colorAliases,
99
- spacingThemeAliases,
100
- radiusThemeAliases,
101
71
  fontSans,
102
72
  "}",
103
73
  "",
package/consumer/init.mjs CHANGED
@@ -77,7 +77,7 @@ export function renderCiSnippet(target, packageManager) {
77
77
  }
78
78
 
79
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.
80
+ // the EDS postinstall available for script maintenance and first-sync bootstrap.
81
81
  function writeAllowScripts(consumerRoot, { log, dryRun }) {
82
82
  const packagePath = path.join(consumerRoot, "package.json");
83
83
  const source = fs.readFileSync(packagePath, "utf8");
@@ -98,7 +98,7 @@ function writeAllowScripts(consumerRoot, { log, dryRun }) {
98
98
  }
99
99
  consumerPackage[allowScriptsField] = { ...(existing ?? {}), [packageName]: true };
100
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.");
101
+ log("[EDS] wrote " + allowScriptsField + "[\"" + packageName + "\"] = true to package.json so npm " + minimumAllowScriptsNpmMajor + "+ can run the EDS postinstall on later installs.");
102
102
  return true;
103
103
  }
104
104
 
@@ -113,24 +113,23 @@ function printInstallScriptGuidance(consumerRoot, packageManager, { log, dryRun,
113
113
  }
114
114
  } else if (major === null) {
115
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 + ".");
116
+ "+, run " + approveCommand + ", or rerun " + cliCommand(packageManager, "init") + " so init can enable the EDS postinstall.");
117
117
  }
118
118
  return;
119
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.
120
+ // Install hooks are optional in package managers that require dependency build approval.
122
121
  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) + ".");
122
+ log("[EDS] pnpm: postinstall can sync EDS files but does not persist package.json script edits. The coding agent registers them with " +
123
+ cliCommand(packageManager, "sync") + " at the start of its next UI task.");
125
124
  return;
126
125
  }
127
126
  if (name === "bun") {
128
- log("[EDS] Optional (bun): to let the EDS postinstall re-register the scripts on later installs, add \"trustedDependencies\": [\"" +
127
+ log("[EDS] Optional (bun): to let the EDS postinstall bootstrap files and maintain scripts, add \"trustedDependencies\": [\"" +
129
128
  packageName + "\"] to package.json. It replaces bun's default trusted list, so this is the project owner's decision.");
130
129
  return;
131
130
  }
132
131
  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 " +
132
+ log("[EDS] Optional (Yarn 2+): to let the EDS postinstall bootstrap files and maintain scripts, keep enableScripts: true in .yarnrc.yml " +
134
133
  "or add \"dependenciesMeta\": { \"" + packageName + "\": { \"built\": true } } to package.json.");
135
134
  }
136
135
  }
@@ -159,7 +158,7 @@ export function runInit({
159
158
  1,
160
159
  );
161
160
  }
162
- // A workspace root or a parent folder can resolve a hoisted EDS; the scripts, hooks and AGENTS.md belong to the
161
+ // A workspace root or a parent folder can resolve a hoisted EDS; the scripts and AGENTS.md belong to the
163
162
  // app package that declares it.
164
163
  const consumerPackage = readPackageJson(consumerRoot);
165
164
  const declared = ["dependencies", "devDependencies", "optionalDependencies"].some((field) => consumerPackage?.[field]?.[packageName] !== undefined);
@@ -182,5 +181,5 @@ export function runInit({
182
181
  log("[EDS] Run " + registered.syncRunCommand + " to finish setup.");
183
182
  return 0;
184
183
  }
185
- return runSync({ consumerRoot, env, dryRun, log });
184
+ return runSync({ consumerRoot, env, dryRun, ensureScripts: false, log });
186
185
  }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { renderRunnerCommands } from "./paths.mjs";
3
+ import { renderRunnerCommands, runnerCommand } from "./paths.mjs";
4
4
 
5
5
  export const beginMarker = "<!-- EDS:BEGIN -->";
6
6
  export const endMarker = "<!-- EDS:END -->";
@@ -21,13 +21,19 @@ export function readTemplateBlock(packageRoot) {
21
21
 
22
22
  // The template is written for npm; the rendered block uses the consumer's package-manager runner
23
23
  // (pnpm, yarn, bun run) so the commands an agent reads work in that repository.
24
- export function renderBlock(template, version, packageManager = null) {
24
+ export function renderBlock(template, version, packageManager = null, commands = null) {
25
25
  const versionLine = "<!-- EDS:VERSION " + version + " -->";
26
26
  const withoutVersion = template.replace(/^<!-- EDS:VERSION \S+ -->\r?\n/m, "");
27
27
  if (!withoutVersion.startsWith(beginMarker)) {
28
28
  throw new Error("CONSUMER_AGENT.md managed block must start with " + beginMarker + ".");
29
29
  }
30
- const body = renderRunnerCommands(withoutVersion.slice(beginMarker.length), packageManager);
30
+ let body = renderRunnerCommands(withoutVersion.slice(beginMarker.length), packageManager);
31
+ if (commands?.sync) {
32
+ body = body.replaceAll(runnerCommand(packageManager, "sync:eds"), commands.sync);
33
+ }
34
+ if (commands?.doctor) {
35
+ body = body.replaceAll(runnerCommand(packageManager, "eds:doctor"), commands.doctor);
36
+ }
31
37
  return beginMarker + "\n" + versionLine + body;
32
38
  }
33
39
 
@@ -20,10 +20,9 @@ const legacyScripts = {
20
20
  "update:eds-agent-context": [legacyRunnerCommand],
21
21
  "generate:eds-tailwind": [legacyRunnerCommand],
22
22
  };
23
- const hookScripts = [
24
- { name: "pretypecheck", target: "typecheck" },
25
- { name: "prebuild", target: "build" },
26
- ];
23
+ const obsoleteDoctorHooks = new Set(["pretypecheck", "prebuild"]);
24
+ const doctorPrefix = doctorCommand + " && ";
25
+ const doctorLifecycleScripts = ["pretypecheck", "prebuild", "typecheck", "build"];
27
26
 
28
27
  export function writeConsumerPackageJson(packagePath, source, consumerPackage) {
29
28
  const trailingNewline = source.endsWith("\n") ? "\n" : "";
@@ -40,15 +39,22 @@ export function isCompatibleCommand(value, command) {
40
39
  return new RegExp("(^|[\\s&|;(])" + escaped + "($|[\\s&|;)])").test(value);
41
40
  }
42
41
 
42
+ export function canUseConsumerScript(scripts, name, command, { willRegister = false } = {}) {
43
+ if (isCompatibleCommand(scripts[name], command)) {
44
+ return true;
45
+ }
46
+ return willRegister && (scripts[name] === undefined || legacyScripts[name]?.includes(scripts[name]) === true);
47
+ }
48
+
43
49
  export function registerConsumerScripts(
44
50
  consumerRoot,
45
- { log = console.log, dryRun = false, packageManager = detectPackageManager(consumerRoot) } = {},
51
+ { log = console.log, dryRun = false, logNoop = true, packageManager = detectPackageManager(consumerRoot) } = {},
46
52
  ) {
47
53
  const packagePath = path.join(consumerRoot, "package.json");
48
54
  const source = fs.readFileSync(packagePath, "utf8");
49
55
  const consumerPackage = JSON.parse(source);
50
56
  const scripts = { ...(consumerPackage.scripts ?? {}) };
51
- const result = { changed: false, added: [], migrated: [], removed: [], skippedHooks: [], conflicts: [], syncRunCommand: null };
57
+ const result = { changed: false, added: [], migrated: [], removed: [], cleanedDoctorScripts: [], conflicts: [], syncRunCommand: null };
52
58
 
53
59
  for (const [name, command] of Object.entries(requiredScripts)) {
54
60
  if (isCompatibleCommand(scripts[name], command)) {
@@ -79,51 +85,47 @@ export function registerConsumerScripts(
79
85
  }
80
86
  }
81
87
 
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)) {
88
+ // Previous EDS versions ran Doctor as an npm lifecycle hook, or told Yarn Berry users to prefix these
89
+ // commands. Remove that automatic check while preserving any command that followed the Doctor prefix.
90
+ for (const name of doctorLifecycleScripts) {
91
+ if (obsoleteDoctorHooks.has(name) && scripts[name] === doctorCommand) {
92
+ delete scripts[name];
93
+ result.cleanedDoctorScripts.push(name);
89
94
  continue;
90
95
  }
91
- if (scripts[hook.name] === undefined) {
92
- scripts[hook.name] = doctorCommand;
93
- result.added.push(hook.name);
94
- continue;
96
+ if (typeof scripts[name] === "string" && scripts[name].startsWith(doctorPrefix)) {
97
+ const remainingCommand = scripts[name].slice(doctorPrefix.length).trimStart();
98
+ if (remainingCommand.length > 0) {
99
+ scripts[name] = remainingCommand;
100
+ result.cleanedDoctorScripts.push(name);
101
+ }
95
102
  }
96
- result.skippedHooks.push(hook.name);
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
- );
101
103
  }
102
104
 
103
- result.changed = result.added.length > 0 || result.migrated.length > 0 || result.removed.length > 0;
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.
105
+ result.changed = result.added.length > 0 || result.migrated.length > 0 || result.removed.length > 0 || result.cleanedDoctorScripts.length > 0;
106
+ // When sync:eds belongs to the consumer, point at the CLI instead of their script. The caller uses this when it
107
+ // cannot safely ensure that the package script has been registered.
106
108
  result.syncRunCommand = result.conflicts.includes(syncScriptName) ? cliCommand(packageManager, "sync") : runnerCommand(packageManager, syncScriptName);
109
+ const changedScripts = [
110
+ ...result.added,
111
+ ...result.migrated,
112
+ ...result.removed.map((name) => "removed " + name),
113
+ ...result.cleanedDoctorScripts.map((name) => "removed Doctor from " + name),
114
+ ];
107
115
  if (result.changed && dryRun) {
108
- log("[EDS] would update package.json scripts: " + [...result.added, ...result.migrated, ...result.removed].join(", "));
116
+ log("[EDS] would update package.json scripts: " + changedScripts.join(", "));
109
117
  } else if (result.changed) {
110
118
  consumerPackage.scripts = scripts;
111
119
  writeConsumerPackageJson(packagePath, source, consumerPackage);
112
- log("[EDS] Added the EDS scripts to package.json: " + [...result.added, ...result.migrated].join(", ") +
113
- (result.removed.length > 0 ? " (removed " + result.removed.join(", ") + ")" : "") + ".");
114
- } else if (result.conflicts.length === 0) {
120
+ log("[EDS] Updated package.json scripts: " + changedScripts.join(", ") + ".");
121
+ if (result.cleanedDoctorScripts.length > 0) {
122
+ log("[EDS] Doctor runs before committing; it is no longer attached to typecheck or build.");
123
+ }
124
+ } else if (result.conflicts.length === 0 && logNoop) {
115
125
  log("[EDS] The EDS scripts are already registered.");
116
126
  } else {
117
127
  log("[EDS] The EDS scripts were left unchanged.");
118
128
  }
119
129
 
120
- // Yarn berry (2+) does not run pre/post lifecycle hooks, so the doctor gate must be inlined by the consumer.
121
- if (packageManager?.name === "yarn" && packageManager.berry && hookTargets.length > 0) {
122
- log(
123
- "[EDS] Yarn 2+ does not run pretypecheck/prebuild hooks. Prepend \"" + doctorCommand +
124
- " && \" to your " + hookTargets.join(" and ") + " script" + (hookTargets.length > 1 ? "s" : "") +
125
- " to keep the doctor gate (this tool does not edit them).",
126
- );
127
- }
128
130
  return result;
129
131
  }
@@ -10,9 +10,10 @@ const opacity = "(?:/[0-9]{1,3})?";
10
10
 
11
11
  const edsColorClass = new RegExp(boundaryBefore + variantPrefix + colorUtility + "-eds-([A-Za-z0-9-]+)" + opacity + boundaryAfter, "g");
12
12
  const edsTypographyClass = new RegExp(boundaryBefore + variantPrefix + "eds-typography-([A-Za-z0-9-]+)" + boundaryAfter, "g");
13
- const dimensionUtility = "(?:p|px|py|ps|pe|pt|pr|pb|pl|m|mx|my|ms|me|mt|mr|mb|ml|gap|gap-x|gap-y|space-x|space-y|inset|inset-x|inset-y|start|end|top|right|bottom|left|w|min-w|max-w|h|min-h|max-h|size|basis|scroll-m|scroll-p)";
14
- const edsSpacingClass = new RegExp(boundaryBefore + variantPrefix + dimensionUtility + "-eds-(spacing-[A-Za-z0-9-]+)" + boundaryAfter, "g");
15
- const edsRadiusClass = new RegExp(boundaryBefore + variantPrefix + "rounded(?:-(?:s|e|t|r|b|l|ss|se|ee|es|tl|tr|br|bl))?-eds-(radius-[A-Za-z0-9-]+)" + boundaryAfter, "g");
13
+ const retiredDimensionClass = new RegExp(
14
+ boundaryBefore + variantPrefix + "-?[A-Za-z0-9-]+-eds-(?:spacing|radius)-[A-Za-z0-9-]+" + boundaryAfter,
15
+ "g",
16
+ );
16
17
  const rawColorClass = new RegExp(
17
18
  boundaryBefore + variantPrefix + colorUtility +
18
19
  "-(?:(?: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\\()[^\\]]*\\])" +
@@ -20,11 +21,10 @@ const rawColorClass = new RegExp(
20
21
  "g",
21
22
  );
22
23
 
23
- // The public class names are derived exactly like eds sync derives the Tailwind aliases from tokens.json.
24
+ // EDS publishes color and typography utilities. Spacing and radius use Tailwind CSS v4 defaults.
24
25
  export function publicClassNames(manifest) {
25
26
  const colors = new Set();
26
27
  const typography = new Set();
27
- const dimensions = new Set();
28
28
  for (const name of Object.keys(manifest.tokens ?? {})) {
29
29
  if (name.startsWith("color-")) {
30
30
  colors.add("eds-" + name.slice("color-".length));
@@ -35,18 +35,15 @@ export function publicClassNames(manifest) {
35
35
  typography.add(match[1]);
36
36
  continue;
37
37
  }
38
- if (name.startsWith("spacing-") || name.startsWith("radius-")) {
39
- dimensions.add("eds-" + name.replace(/\./g, "-"));
40
- }
41
38
  }
42
- return { colors, typography, dimensions };
39
+ return { colors, typography };
43
40
  }
44
41
 
45
42
  // files: [{ path, contents }] as readSources() lists them (the generated eds-tokens.css is not among them).
46
43
  // unknown: eds-* classes that no public token backs (always a mistake, Tailwind silently drops them).
47
44
  // raw: Tailwind palette, white/black and arbitrary color classes that bypass the EDS tokens (informational).
48
45
  export function scanClassNames({ consumerRoot, manifest, files }) {
49
- const { colors, typography, dimensions } = publicClassNames(manifest);
46
+ const { colors, typography } = publicClassNames(manifest);
50
47
  const unknown = [];
51
48
  const raw = [];
52
49
  for (const file of files) {
@@ -70,15 +67,8 @@ export function scanClassNames({ consumerRoot, manifest, files }) {
70
67
  unknown.push({ location, className: match[0] });
71
68
  }
72
69
  }
73
- for (const match of line.matchAll(edsSpacingClass)) {
74
- if (match[1] !== undefined && !dimensions.has("eds-" + match[1])) {
75
- unknown.push({ location, className: match[0] });
76
- }
77
- }
78
- for (const match of line.matchAll(edsRadiusClass)) {
79
- if (match[1] !== undefined && !dimensions.has("eds-" + match[1])) {
80
- unknown.push({ location, className: match[0] });
81
- }
70
+ for (const match of line.matchAll(retiredDimensionClass)) {
71
+ unknown.push({ location, className: match[0] });
82
72
  }
83
73
  for (const match of line.matchAll(rawColorClass)) {
84
74
  raw.push({ location, className: match[0] });
package/consumer/sync.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "./css-root.mjs";
14
14
  import { planTailwind } from "./generate-tailwind.mjs";
15
15
  import { cliCommand, createExitError, detectPackageManager, generatedCssRelativePath, normalizeEol, readPackageJson, resolvePackage, runnerCommand, toPosix } from "./paths.mjs";
16
- import { doctorCommand, doctorScriptName, isCompatibleCommand, writeConsumerPackageJson } from "./register-scripts.mjs";
16
+ import { canUseConsumerScript, doctorCommand, doctorScriptName, isCompatibleCommand, registerConsumerScripts, syncCommand, syncScriptName, writeConsumerPackageJson } from "./register-scripts.mjs";
17
17
  import { planAgentContext, planClaudeImport, planDeliveryNotes, planGeminiImport } from "./update-agent-context.mjs";
18
18
 
19
19
  export const syncTargets = new Set(["tailwind", "agents"]);
@@ -31,9 +31,18 @@ export function describeMissingTailwindRoot(plans) {
31
31
  : "Tailwind CSS v3 detected (@tailwind directives in " + plans.tailwindV3File + "); " + skippedWiring + " " + tailwindV3Advice;
32
32
  }
33
33
 
34
- export function collectPlans({ consumerRoot, env = process.env }) {
34
+ export function collectPlans({ consumerRoot, env = process.env, ensureScripts = false }) {
35
35
  const pkg = resolvePackage(consumerRoot);
36
36
  const packageManager = detectPackageManager(consumerRoot, env);
37
+ const scripts = readPackageJson(consumerRoot)?.scripts ?? {};
38
+ const commands = {
39
+ sync: canUseConsumerScript(scripts, syncScriptName, syncCommand, { willRegister: ensureScripts })
40
+ ? runnerCommand(packageManager, syncScriptName)
41
+ : cliCommand(packageManager, "sync"),
42
+ doctor: canUseConsumerScript(scripts, doctorScriptName, doctorCommand, { willRegister: ensureScripts })
43
+ ? runnerCommand(packageManager, doctorScriptName)
44
+ : cliCommand(packageManager, "doctor"),
45
+ };
37
46
  const sources = readSources(consumerRoot);
38
47
  const root = findTailwindRoot(consumerRoot, env, sources, packageManager);
39
48
  // No Tailwind root: the Tailwind wiring is skipped entirely (no generated CSS, no root edits).
@@ -44,7 +53,7 @@ export function collectPlans({ consumerRoot, env = process.env }) {
44
53
  const errors = [];
45
54
  let agents = null;
46
55
  try {
47
- agents = planAgentContext({ consumerRoot, pkg, packageManager });
56
+ agents = planAgentContext({ consumerRoot, pkg, packageManager, commands });
48
57
  } catch (error) {
49
58
  errors.push(error);
50
59
  }
@@ -54,11 +63,11 @@ export function collectPlans({ consumerRoot, env = process.env }) {
54
63
  return { consumerRoot, pkg, packageManager, sources, root, tailwindV3File, tailwind, cssRoot, legacyImports, agents, claude, gemini, deliveryNotes, errors };
55
64
  }
56
65
 
57
- export function planSync({ consumerRoot, env = process.env, only = null }) {
66
+ export function planSync({ consumerRoot, env = process.env, only = null, ensureScripts = true }) {
58
67
  if (only !== null && !syncTargets.has(only)) {
59
68
  throw createExitError("Unknown --only target: " + only + " (use tailwind or agents).", 2);
60
69
  }
61
- const plans = collectPlans({ consumerRoot, env });
70
+ const plans = collectPlans({ consumerRoot, env, ensureScripts: ensureScripts && only === null });
62
71
  if (plans.errors.length > 0) {
63
72
  // Same wording as the doctor line, so the CONSUMER_AGENT.md table applies to both.
64
73
  plans.errors[0].message = "invalid: " + plans.errors[0].message;
@@ -83,7 +92,7 @@ export function planSync({ consumerRoot, env = process.env, only = null }) {
83
92
  }
84
93
 
85
94
  // EDS_TAILWIND_ROOT is a one-time choice: sync records it as package.json "eds": { "tailwindRoot" } so
86
- // doctor and the pretypecheck/prebuild hooks resolve the same root without the environment variable.
95
+ // later sync and doctor runs resolve the same root without the environment variable.
87
96
  function persistTailwindRoot(plan, { dryRun, log }) {
88
97
  if (plan.root?.source !== "env" || plan.only === "agents") {
89
98
  return false;
@@ -94,7 +103,7 @@ function persistTailwindRoot(plan, { dryRun, log }) {
94
103
  const existing = consumerPackage[consumerConfigField];
95
104
  if (existing !== undefined && (typeof existing !== "object" || existing === null || Array.isArray(existing))) {
96
105
  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 \"" +
106
+ "With several Tailwind CSS candidates, doctor and sync exit 2 again. Check what uses the existing \"" +
98
107
  consumerConfigField + "\" value and move it so \"" + consumerConfigField + "\" can be an object, or set " + tailwindRootEnvironmentVariable + " on every run.");
99
108
  return false;
100
109
  }
@@ -109,7 +118,7 @@ function persistTailwindRoot(plan, { dryRun, log }) {
109
118
  }
110
119
  consumerPackage[consumerConfigField] = { ...(existing ?? {}), [tailwindRootConfigKey]: value };
111
120
  writeConsumerPackageJson(packagePath, source, consumerPackage);
112
- log("[EDS] wrote " + label + " to package.json; doctor, sync and the hooks now use this Tailwind root without " + tailwindRootEnvironmentVariable + ".");
121
+ log("[EDS] wrote " + label + " to package.json; doctor and sync now use this Tailwind root without " + tailwindRootEnvironmentVariable + ".");
113
122
  return true;
114
123
  }
115
124
 
@@ -143,13 +152,17 @@ export function applySync(plan, { dryRun = false, log = console.log } = {}) {
143
152
  const doctorRun = isCompatibleCommand(scripts[doctorScriptName], doctorCommand)
144
153
  ? runnerCommand(plan.packageManager, doctorScriptName)
145
154
  : cliCommand(plan.packageManager, "doctor");
146
- log("[EDS] Synced with " + version + ". Include the files written above in your change, then run " + doctorRun + ".");
155
+ log("[EDS] Synced with " + version + ". Include the files written above in your change; before committing, run " + doctorRun + ".");
147
156
  }
148
157
  return plan.writes.map((write) => write.relativePath);
149
158
  }
150
159
 
151
- export function runSync({ consumerRoot, env = process.env, only = null, dryRun = false, log = console.log }) {
152
- const plan = planSync({ consumerRoot, env, only });
160
+ export function runSync({ consumerRoot, env = process.env, only = null, dryRun = false, ensureScripts = true, log = console.log }) {
161
+ const plan = planSync({ consumerRoot, env, only, ensureScripts });
162
+ // Keep script setup and cleanup in the same one-time task-start command, including package managers that skip postinstall.
163
+ if (ensureScripts && only === null) {
164
+ registerConsumerScripts(consumerRoot, { log, dryRun, logNoop: false, packageManager: plan.packageManager });
165
+ }
153
166
  applySync(plan, { dryRun, log });
154
167
  return 0;
155
168
  }
@@ -17,12 +17,12 @@ export const geminiImportLine = "@./AGENTS.md";
17
17
  const importPattern = /^[ \t]*@(?:\.\.?\/)?AGENTS\.md[ \t]*$/m;
18
18
  const endMarkerText = "<!-- EDS:END -->";
19
19
 
20
- export function planAgentContext({ consumerRoot, pkg, packageManager = null }) {
20
+ export function planAgentContext({ consumerRoot, pkg, packageManager = null, commands = null }) {
21
21
  const filePath = path.join(consumerRoot, "AGENTS.md");
22
22
  const before = readTextIfExists(filePath);
23
23
  // The block is rendered with the target file's dominant line ending; a new file uses LF.
24
24
  const eol = before === null ? "\n" : detectEol(before);
25
- const block = withEol(renderBlock(readTemplateBlock(pkg.packageRoot), pkg.version, packageManager), eol);
25
+ const block = withEol(renderBlock(readTemplateBlock(pkg.packageRoot), pkg.version, packageManager, commands), eol);
26
26
  const located = before === null ? null : locateBlock(before);
27
27
  return {
28
28
  label: "agents",
@@ -1,7 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { locateBlock, readBlockVersion } from "./consumer/managed-block.mjs";
5
+ import { cliCommand, detectPackageManager, resolvePackage } from "./consumer/paths.mjs";
4
6
  import { registerConsumerScripts } from "./consumer/register-scripts.mjs";
7
+ import { runSync } from "./consumer/sync.mjs";
5
8
 
6
9
  const packageRoot = path.dirname(fileURLToPath(import.meta.url));
7
10
  const consumerRoot = process.env.INIT_CWD;
@@ -12,7 +15,7 @@ if (!consumerRoot || path.resolve(consumerRoot) === packageRoot) {
12
15
 
13
16
  const consumerPackagePath = path.join(consumerRoot, "package.json");
14
17
  if (!fs.existsSync(consumerPackagePath)) {
15
- console.log("[EDS] Consumer package.json was not found; run npx @spectrabrain/eds init in the package that depends on @spectrabrain/eds.");
18
+ console.log("[EDS] Consumer package.json was not found; the coding agent should run EDS setup in the app package that depends on EDS.");
16
19
  process.exit(0);
17
20
  }
18
21
 
@@ -26,35 +29,90 @@ function realpathOrNull(filePath) {
26
29
 
27
30
  const installedPath = realpathOrNull(path.join(consumerRoot, "node_modules", "@spectrabrain", "eds"));
28
31
  if (installedPath === null || installedPath !== realpathOrNull(packageRoot)) {
29
- console.log("[EDS] Skipping consumer setup: this install is not the consumer's node_modules/@spectrabrain/eds. Run npx @spectrabrain/eds init in the package that depends on @spectrabrain/eds.");
32
+ console.log("[EDS] Skipping consumer setup: this is not the app package's direct EDS install. The coding agent should set up EDS in the app package that depends on it.");
30
33
  process.exit(0);
31
34
  }
32
35
 
33
- // pnpm rewrites the consumer package.json after dependency lifecycle scripts, so edits made here are lost.
34
- if (String(process.env.npm_config_user_agent ?? "").startsWith("pnpm/")) {
35
- console.log("[EDS] pnpm discards package.json edits made during install; run pnpm exec eds init to register the EDS scripts.");
36
+ let consumerPackage;
37
+ try {
38
+ consumerPackage = JSON.parse(fs.readFileSync(consumerPackagePath, "utf8"));
39
+ } catch (error) {
40
+ console.log("[EDS] Consumer package.json could not be parsed (" + error.message + "); the coding agent should run EDS setup after fixing it.");
36
41
  process.exit(0);
37
42
  }
38
43
 
39
- let consumerPackage;
44
+ let packageManager;
45
+ let packageJson;
40
46
  try {
41
- consumerPackage = JSON.parse(fs.readFileSync(consumerPackagePath, "utf8"));
47
+ packageManager = detectPackageManager(consumerRoot);
48
+ packageJson = resolvePackage(consumerRoot);
42
49
  } catch (error) {
43
- console.log("[EDS] Consumer package.json could not be parsed (" + error.message + "); run npx @spectrabrain/eds init after fixing it.");
50
+ console.log("[EDS] Consumer setup was skipped (" + (error instanceof Error ? error.message : String(error)) + ").");
44
51
  process.exit(0);
45
52
  }
46
53
 
47
- // A workspaces root hoists the package but is not the app that uses it: registering the scripts and hooks
48
- // there would make the root build look for src/styles/eds-tokens.css and AGENTS.md in the wrong place.
54
+ const initCommand = cliCommand(packageManager, "init");
49
55
  if (consumerPackage.workspaces !== undefined) {
50
- console.log("[EDS] Skipping consumer setup: package.json declares workspaces. Run npx @spectrabrain/eds init inside the app package that depends on @spectrabrain/eds.");
56
+ console.log("[EDS] Skipping consumer setup at the workspaces root. The coding agent should run " + initCommand + " inside the app package that depends on EDS.");
57
+ process.exit(0);
58
+ }
59
+
60
+ const agentsPath = path.join(consumerRoot, "AGENTS.md");
61
+ let existingManagedBlock = null;
62
+ let markerVersion = null;
63
+ try {
64
+ if (fs.existsSync(agentsPath)) {
65
+ const block = locateBlock(fs.readFileSync(agentsPath, "utf8"));
66
+ existingManagedBlock = block?.block ?? null;
67
+ markerVersion = existingManagedBlock === null ? null : readBlockVersion(existingManagedBlock);
68
+ }
69
+ } catch (error) {
70
+ console.log(
71
+ "[EDS] Initial sync was skipped (" + (error instanceof Error ? error.message : String(error)) + "); " +
72
+ "the coding agent should repair the EDS markers before syncing.",
73
+ );
51
74
  process.exit(0);
52
75
  }
53
76
 
54
- // The install must never fail because of this setup step; anything unexpected is reported and left to eds doctor.
77
+ const pnpmInstall = String(process.env.npm_config_user_agent ?? "").startsWith("pnpm/");
78
+ if (!pnpmInstall) {
79
+ try {
80
+ registerConsumerScripts(consumerRoot, { packageManager });
81
+ } catch (error) {
82
+ console.log("[EDS] Script maintenance was skipped (" + (error instanceof Error ? error.message : String(error)) + ").");
83
+ }
84
+ }
85
+
86
+ if (existingManagedBlock !== null && markerVersion === null) {
87
+ console.log(
88
+ "[EDS] The existing AGENTS.md EDS block has no version marker; scripts were maintained without changing files, and the coding agent will read the installed version's CHANGELOG.md actions and sync at the start of the next UI task.",
89
+ );
90
+ process.exit(0);
91
+ }
92
+
93
+ if (markerVersion !== null) {
94
+ if (markerVersion !== packageJson.version) {
95
+ console.log(
96
+ "[EDS] Installed EDS version " + packageJson.version + " differs from the AGENTS.md marker " + markerVersion + "; " +
97
+ "the coding agent will read the version's CHANGELOG.md actions and sync at the start of the next UI task.",
98
+ );
99
+ } else {
100
+ console.log("[EDS] The AGENTS.md version marker matches; the coding agent will check generated files with its next task-start sync.");
101
+ }
102
+ process.exit(0);
103
+ }
104
+
105
+ // pnpm restores package.json after dependency lifecycle scripts, so sync its initial managed files now but let the agent
106
+ // register scripts with its first task-start sync. Other package managers keep both scripts and files.
55
107
  try {
56
- const result = registerConsumerScripts(consumerRoot);
57
- console.log("[EDS] Run " + result.syncRunCommand + " to finish setup.");
108
+ runSync({ consumerRoot, ensureScripts: false });
109
+ if (pnpmInstall) {
110
+ console.log("[EDS] pnpm leaves package.json scripts to the coding agent's first task-start sync.");
111
+ }
112
+ console.log("[EDS] Initial EDS sync is ready; the managed instructions tell the coding agent when to sync on later tasks.");
58
113
  } catch (error) {
59
- console.log("[EDS] Consumer setup was skipped (" + (error instanceof Error ? error.message : String(error)) + "). Run npx @spectrabrain/eds init to register the EDS scripts.");
114
+ console.log(
115
+ "[EDS] Initial sync was skipped (" + (error instanceof Error ? error.message : String(error)) + "); " +
116
+ "the coding agent should resolve the finding and run " + cliCommand(packageManager, "sync") + ".",
117
+ );
60
118
  }
@@ -65,10 +65,8 @@ Layout은 prop으로 지정하지 않고 슬롯 조합에서 추론합니다.
65
65
  | `iconBoth` | `children`과 앞·뒤 아이콘을 모두 지정합니다. | 양쪽 아이콘이 모두 의미를 더하는 명확한 경우에만 사용합니다. |
66
66
  | `iconOnly` | `children` 없이 `icon` 또는 `iconName`과 `aria-label`을 지정합니다. | |
67
67
 
68
- - 이름 슬롯(`iconName`·`startIconName`·`endIconName`)은 승인된 아이콘 목록의 lucide PascalCase 전체 이름만 받습니다. `ButtonIconName`이 이 목록과 같은 union이라 목록에 없는 이름은 TypeScript 오류이고, 런타임에 전달되면 render 오류입니다. 전체 목록은 설치된 패키지 `spec.json`의 `api.iconNames`에 있습니다.
69
- - 목록은 `lucide-react`의 canonical 이름만 포함하며 `AlertCircle`처럼 deprecated된 alias는 허용하지 않습니다.
70
- - 목록 밖 아이콘은 `icon`, `startIcon`, `endIcon`에 `ReactElement`로 전달하며, 같은 슬롯에 이름과 custom element를 함께 지정하지 않습니다. `lucide-react`에서 직접 import할 때는 EDS package root `package.json`의 `dependencies["lucide-react"]`와 같은 exact 버전을 소비처 `package.json`에 선언합니다. pnpm처럼 엄격한 node_modules에서는 선언 없이 resolve되지 않고, 버전이 다르면 아이콘 코드가 두 벌 번들됩니다. custom lucide 아이콘은 lucide 기본 stroke를 그대로 써서 이름으로 지정한 아이콘과 맞춥니다.
71
- - 목록 밖 아이콘을 `ReactElement`로 썼다면 완료 보고에 아이콘 이름과 사용 파일을 allowlist 추가 후보로 적습니다.
68
+ - 이름 슬롯(`iconName`·`startIconName`·`endIconName`)은 설치된 `lucide-react`의 실제 아이콘 component 이름과 alias를 모두 받습니다. `ButtonIconName`이 이 목록을 자동완성하며, 전체 이름은 `spec.json`의 `api.iconNames`에 있습니다. 잘못된 이름은 TypeScript 오류이고 런타임에도 render 오류입니다.
69
+ - custom 아이콘은 `icon`·`startIcon`·`endIcon`에 `ReactElement`로 전달합니다. 같은 슬롯에 이름과 custom element를 함께 지정하지 않습니다. `lucide-react`에서 직접 import할 때는 EDS package root `package.json`의 `dependencies["lucide-react"]`와 같은 exact 버전을 소비처 `package.json`에 선언합니다. pnpm처럼 엄격한 node_modules에서는 선언 없이 resolve되지 않고, 버전이 다르면 아이콘 코드가 두 벌 번들됩니다. 이름 슬롯은 설치된 아이콘 크기와 stroke를 사용합니다.
72
70
 
73
71
  ### State
74
72