@spectrabrain/eds 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { renderRunnerCommands } from "./paths.mjs";
4
+
5
+ export const beginMarker = "<!-- EDS:BEGIN -->";
6
+ export const endMarker = "<!-- EDS:END -->";
7
+ export const versionPattern = /^<!-- EDS:VERSION (\S+) -->$/m;
8
+ export const legacyVersionLabel = "pre-0.1.0";
9
+
10
+ const fenceOpenPattern = /^[ \t]{0,3}(`{3,}|~{3,})/;
11
+
12
+ export function readTemplateBlock(packageRoot) {
13
+ const guide = fs.readFileSync(path.join(packageRoot, "CONSUMER_AGENT.md"), "utf8");
14
+ const beginIndex = guide.indexOf(beginMarker);
15
+ const endIndex = beginIndex < 0 ? -1 : guide.indexOf(endMarker, beginIndex);
16
+ if (beginIndex < 0 || endIndex < beginIndex) {
17
+ throw new Error("CONSUMER_AGENT.md has an invalid managed block.");
18
+ }
19
+ return guide.slice(beginIndex, endIndex + endMarker.length);
20
+ }
21
+
22
+ // The template is written for npm; the rendered block uses the consumer's package-manager runner
23
+ // (pnpm, yarn, bun run) so the commands an agent reads work in that repository.
24
+ export function renderBlock(template, version, packageManager = null) {
25
+ const versionLine = "<!-- EDS:VERSION " + version + " -->";
26
+ const withoutVersion = template.replace(/^<!-- EDS:VERSION \S+ -->\r?\n/m, "");
27
+ if (!withoutVersion.startsWith(beginMarker)) {
28
+ throw new Error("CONSUMER_AGENT.md managed block must start with " + beginMarker + ".");
29
+ }
30
+ const body = renderRunnerCommands(withoutVersion.slice(beginMarker.length), packageManager);
31
+ return beginMarker + "\n" + versionLine + body;
32
+ }
33
+
34
+ export function readBlockVersion(block) {
35
+ return block.match(versionPattern)?.[1] ?? null;
36
+ }
37
+
38
+ // Replaces the inside of Markdown fenced code blocks (``` or ~~~) with spaces of the same length, so
39
+ // markers quoted as examples are not treated as the real block while every index still maps to the
40
+ // original text. An unclosed fence runs to the end of the file, as in CommonMark.
41
+ export function maskFencedBlocks(text) {
42
+ const lines = text.split(/(\r?\n)/);
43
+ let fence = null;
44
+ const output = [];
45
+ for (let index = 0; index < lines.length; index += 1) {
46
+ const segment = lines[index];
47
+ if (index % 2 === 1) {
48
+ output.push(segment);
49
+ continue;
50
+ }
51
+ if (fence === null) {
52
+ const opened = segment.match(fenceOpenPattern);
53
+ if (opened) {
54
+ fence = opened[1];
55
+ }
56
+ output.push(segment);
57
+ continue;
58
+ }
59
+ const closing = segment.match(fenceOpenPattern);
60
+ if (closing && closing[1][0] === fence[0] && closing[1].length >= fence.length && segment.trim() === closing[1]) {
61
+ fence = null;
62
+ output.push(segment);
63
+ continue;
64
+ }
65
+ output.push(" ".repeat(segment.length));
66
+ }
67
+ return output.join("");
68
+ }
69
+
70
+ function findAll(text, marker) {
71
+ const positions = [];
72
+ let index = text.indexOf(marker);
73
+ while (index >= 0) {
74
+ positions.push(index);
75
+ index = text.indexOf(marker, index + marker.length);
76
+ }
77
+ return positions;
78
+ }
79
+
80
+ function lineNumberAt(text, index) {
81
+ return text.slice(0, index).split(/\r?\n/).length;
82
+ }
83
+
84
+ export function locateBlock(existing) {
85
+ const masked = maskFencedBlocks(existing);
86
+ const begins = findAll(masked, beginMarker);
87
+ const ends = findAll(masked, endMarker);
88
+ if (begins.length !== ends.length || begins.length > 1) {
89
+ const first = begins[0] ?? ends[0];
90
+ throw new Error(
91
+ "AGENTS.md must contain zero or one complete EDS managed block. Found " + begins.length + " " + beginMarker +
92
+ " and " + ends.length + " " + endMarker + " marker" + (begins.length + ends.length === 1 ? "" : "s") +
93
+ " outside fenced code blocks" + (first === undefined ? "" : ", first at line " + lineNumberAt(existing, first)) + ".",
94
+ );
95
+ }
96
+ if (begins.length === 0) {
97
+ return null;
98
+ }
99
+
100
+ const [start] = begins;
101
+ const [endIndex] = ends;
102
+ if (endIndex < start) {
103
+ throw new Error(
104
+ "AGENTS.md has an invalid EDS managed block: " + endMarker + " at line " + lineNumberAt(existing, endIndex) +
105
+ " comes before " + beginMarker + " at line " + lineNumberAt(existing, start) + ".",
106
+ );
107
+ }
108
+ const end = endIndex + endMarker.length;
109
+ return { start, end, block: existing.slice(start, end) };
110
+ }
111
+
112
+ // `block` must already use `eol`; the separators around it follow the same line ending.
113
+ export function applyBlock(existing, block, eol = "\n") {
114
+ const located = existing === null ? null : locateBlock(existing);
115
+ if (located) {
116
+ return existing.slice(0, located.start) + block + existing.slice(located.end);
117
+ }
118
+ const trimmed = (existing ?? "").replace(/\s*$/, "");
119
+ return trimmed ? trimmed + eol + eol + block + eol : block + eol;
120
+ }
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+
5
+ export const packageName = "@spectrabrain/eds";
6
+ export const generatedCssRelativePath = path.join("src", "styles", "eds-tokens.css");
7
+
8
+ const excludedDirectories = new Set([
9
+ "node_modules",
10
+ "dist",
11
+ "build",
12
+ ".git",
13
+ "storybook-static",
14
+ "coverage",
15
+ ]);
16
+ const cssExtensions = new Set([".css"]);
17
+ const scriptExtensions = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
18
+
19
+ export function createExitError(message, exitCode) {
20
+ const error = new Error(message);
21
+ error.exitCode = exitCode;
22
+ return error;
23
+ }
24
+
25
+ export function resolvePackage(consumerRoot) {
26
+ const require = createRequire(path.join(consumerRoot, "package.json"));
27
+ let packageJsonPath;
28
+ try {
29
+ packageJsonPath = require.resolve(packageName + "/package.json");
30
+ } catch {
31
+ throw createExitError(packageName + " is not installed here (run npm install).", 2);
32
+ }
33
+
34
+ const packageRoot = path.dirname(packageJsonPath);
35
+ let packageJson;
36
+ try {
37
+ packageJson = readJson(packageJsonPath);
38
+ } catch (error) {
39
+ throw createExitError(packageName + "/package.json could not be read: " + error.message, 2);
40
+ }
41
+ return {
42
+ packageRoot,
43
+ packageJson,
44
+ version: String(packageJson.version ?? ""),
45
+ resolve(specifier) {
46
+ return require.resolve(packageName + "/" + specifier);
47
+ },
48
+ };
49
+ }
50
+
51
+ export function resolvePackageRoot(consumerRoot) {
52
+ return resolvePackage(consumerRoot).packageRoot;
53
+ }
54
+
55
+ export function readJson(filePath) {
56
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
57
+ }
58
+
59
+ export function readPackageJson(directory) {
60
+ const filePath = path.join(directory, "package.json");
61
+ if (!fs.existsSync(filePath)) {
62
+ return null;
63
+ }
64
+ return readJson(filePath);
65
+ }
66
+
67
+ export function readTextIfExists(filePath) {
68
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
69
+ }
70
+
71
+ export function listFiles(directory, extensions) {
72
+ if (!fs.existsSync(directory)) {
73
+ return [];
74
+ }
75
+ const files = [];
76
+ const walk = (current) => {
77
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
78
+ const entryPath = path.join(current, entry.name);
79
+ if (entry.isDirectory()) {
80
+ if (excludedDirectories.has(entry.name) || entry.name.startsWith(".")) {
81
+ continue;
82
+ }
83
+ walk(entryPath);
84
+ continue;
85
+ }
86
+ if (entry.isFile() && extensions.has(path.extname(entry.name))) {
87
+ files.push(entryPath);
88
+ }
89
+ }
90
+ };
91
+ walk(directory);
92
+ return files.sort();
93
+ }
94
+
95
+ export function listCssFiles(consumerRoot) {
96
+ return listFiles(consumerRoot, cssExtensions);
97
+ }
98
+
99
+ export function listScriptFiles(directory) {
100
+ return listFiles(directory, scriptExtensions);
101
+ }
102
+
103
+ export function toPosix(filePath) {
104
+ return filePath.split(path.sep).join("/");
105
+ }
106
+
107
+ export function relativeTo(consumerRoot, filePath) {
108
+ return toPosix(path.relative(consumerRoot, filePath));
109
+ }
110
+
111
+ export function relativeImportPath(fromFile, toFile) {
112
+ const relative = toPosix(path.relative(path.dirname(fromFile), toFile));
113
+ return relative.startsWith(".") ? relative : "./" + relative;
114
+ }
115
+
116
+ export function detectIndent(text) {
117
+ return text.match(/^([ \t]+)"/m)?.[1] ?? " ";
118
+ }
119
+
120
+ export function detectEol(text) {
121
+ return text.includes("\r\n") ? "\r\n" : "\n";
122
+ }
123
+
124
+ // Re-renders LF (or mixed) text with the target file's dominant line ending.
125
+ export function withEol(text, eol) {
126
+ return text.replace(/\r?\n/g, eol);
127
+ }
128
+
129
+ // Comparisons (doctor) treat CRLF and LF as equal so an autocrlf checkout is not reported as stale.
130
+ export function normalizeEol(text) {
131
+ return text === null || text === undefined ? text : text.replace(/\r\n/g, "\n");
132
+ }
133
+
134
+ export function realpathOrNull(filePath) {
135
+ try {
136
+ return fs.realpathSync(filePath);
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ const runnerPrefixes = {
143
+ npm: "npm run ",
144
+ pnpm: "pnpm ",
145
+ yarn: "yarn ",
146
+ bun: "bun run ",
147
+ };
148
+ const lockfileManagers = [
149
+ ["pnpm-lock.yaml", "pnpm"],
150
+ ["yarn.lock", "yarn"],
151
+ ["bun.lock", "bun"],
152
+ ["bun.lockb", "bun"],
153
+ ["package-lock.json", "npm"],
154
+ ["npm-shrinkwrap.json", "npm"],
155
+ ];
156
+ const htmlExtensions = new Set([".html"]);
157
+
158
+ export function parseUserAgent(userAgent) {
159
+ const match = String(userAgent ?? "").match(/^(npm|pnpm|yarn|bun)\/(\d+)(?:\.(\d+))?(?:\.(\d+))?(\S*)/);
160
+ if (!match) {
161
+ return null;
162
+ }
163
+ const [, name, major, minor, patch, rest] = match;
164
+ return { name, version: [major, minor ?? "0", patch ?? "0"].join(".") + rest, major: Number.parseInt(major, 10) };
165
+ }
166
+
167
+ function isYarnBerry(consumerRoot, major) {
168
+ if (major !== null) {
169
+ return major >= 2;
170
+ }
171
+ if (fs.existsSync(path.join(consumerRoot, ".yarnrc.yml"))) {
172
+ return true;
173
+ }
174
+ const lockfile = readTextIfExists(path.join(consumerRoot, "yarn.lock"));
175
+ return lockfile !== null && /^__metadata:/m.test(lockfile);
176
+ }
177
+
178
+ export function detectPackageManager(consumerRoot, env = process.env) {
179
+ const fromAgent = parseUserAgent(env.npm_config_user_agent);
180
+ let name = fromAgent?.name ?? null;
181
+ let source = fromAgent ? "user-agent" : "lockfile";
182
+ if (name === null) {
183
+ name = lockfileManagers.find(([fileName]) => fs.existsSync(path.join(consumerRoot, fileName)))?.[1] ?? null;
184
+ }
185
+ if (name === null) {
186
+ name = "npm";
187
+ source = "default";
188
+ }
189
+ const major = fromAgent?.major ?? null;
190
+ return {
191
+ name,
192
+ version: fromAgent?.version ?? null,
193
+ major,
194
+ berry: name === "yarn" && isYarnBerry(consumerRoot, major),
195
+ runner: runnerPrefixes[name],
196
+ source,
197
+ };
198
+ }
199
+
200
+ export function runnerCommand(packageManager, scriptName) {
201
+ return (packageManager?.runner ?? runnerPrefixes.npm) + scriptName;
202
+ }
203
+
204
+ export function renderRunnerCommands(text, packageManager) {
205
+ const runner = packageManager?.runner ?? runnerPrefixes.npm;
206
+ return runner === runnerPrefixes.npm ? text : text.replace(/\bnpm run /g, runner);
207
+ }
208
+
209
+ export function listHtmlFiles(consumerRoot) {
210
+ return listFiles(consumerRoot, htmlExtensions);
211
+ }
@@ -0,0 +1,124 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { detectIndent, detectPackageManager, runnerCommand } from "./paths.mjs";
4
+
5
+ export const syncCommand = "eds sync";
6
+ export const doctorCommand = "eds doctor";
7
+ export const syncScriptName = "sync:eds";
8
+ export const doctorScriptName = "eds:doctor";
9
+
10
+ const legacyRunnerCommand = "node --input-type=module -e \"import('@spectrabrain/eds/consumer-sync.mjs')\"";
11
+ const requiredScripts = {
12
+ [syncScriptName]: syncCommand,
13
+ [doctorScriptName]: doctorCommand,
14
+ };
15
+ const legacyScripts = {
16
+ [syncScriptName]: [
17
+ "npm run update:eds-agent-context && npm run generate:eds-tailwind",
18
+ "npm run update:eds-agent-context && npm run generate-eds-tailwind",
19
+ ],
20
+ "update:eds-agent-context": [legacyRunnerCommand],
21
+ "generate:eds-tailwind": [legacyRunnerCommand],
22
+ };
23
+ const hookScripts = [
24
+ { name: "pretypecheck", target: "typecheck" },
25
+ { name: "prebuild", target: "build" },
26
+ ];
27
+
28
+ export function writeConsumerPackageJson(packagePath, source, consumerPackage) {
29
+ const trailingNewline = source.endsWith("\n") ? "\n" : "";
30
+ fs.writeFileSync(packagePath, JSON.stringify(consumerPackage, null, detectIndent(source)) + trailingNewline);
31
+ }
32
+
33
+ // A consumer may wrap the EDS command (for example `cross-env EDS_DOCTOR=warn eds doctor`). Any script that
34
+ // still runs the EDS command counts as registered; only scripts that do not run it are reported.
35
+ export function isCompatibleCommand(value, command) {
36
+ if (typeof value !== "string") {
37
+ return false;
38
+ }
39
+ const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40
+ return new RegExp("(^|[\\s&|;(])" + escaped + "($|[\\s&|;)])").test(value);
41
+ }
42
+
43
+ export function registerConsumerScripts(
44
+ consumerRoot,
45
+ { log = console.log, dryRun = false, packageManager = detectPackageManager(consumerRoot) } = {},
46
+ ) {
47
+ const packagePath = path.join(consumerRoot, "package.json");
48
+ const source = fs.readFileSync(packagePath, "utf8");
49
+ const consumerPackage = JSON.parse(source);
50
+ const scripts = { ...(consumerPackage.scripts ?? {}) };
51
+ const result = { changed: false, added: [], migrated: [], removed: [], skippedHooks: [], conflicts: [] };
52
+
53
+ for (const [name, command] of Object.entries(requiredScripts)) {
54
+ if (isCompatibleCommand(scripts[name], command)) {
55
+ continue;
56
+ }
57
+ if (scripts[name] === undefined) {
58
+ scripts[name] = command;
59
+ result.added.push(name);
60
+ continue;
61
+ }
62
+ if (legacyScripts[name]?.includes(scripts[name])) {
63
+ scripts[name] = command;
64
+ result.migrated.push(name);
65
+ continue;
66
+ }
67
+ // Never fail the install over a consumer-owned script: leave it alone and let doctor report it.
68
+ result.conflicts.push(name);
69
+ log(
70
+ `[EVA Design System] Consumer script ${name} already exists with a different command (${JSON.stringify(scripts[name])}); ` +
71
+ `left unchanged. eds doctor reports it until the script runs "${command}".`,
72
+ );
73
+ }
74
+
75
+ for (const [name, commands] of Object.entries(legacyScripts)) {
76
+ if (requiredScripts[name] === undefined && commands.includes(scripts[name])) {
77
+ delete scripts[name];
78
+ result.removed.push(name);
79
+ }
80
+ }
81
+
82
+ const hookTargets = [];
83
+ for (const hook of hookScripts) {
84
+ if (scripts[hook.target] === undefined) {
85
+ continue;
86
+ }
87
+ hookTargets.push(hook.target);
88
+ if (isCompatibleCommand(scripts[hook.name], doctorCommand)) {
89
+ continue;
90
+ }
91
+ if (scripts[hook.name] === undefined) {
92
+ scripts[hook.name] = doctorCommand;
93
+ result.added.push(hook.name);
94
+ continue;
95
+ }
96
+ result.skippedHooks.push(hook.name);
97
+ log(`[EVA Design System] ${hook.name} is already defined; add "${doctorCommand}" to it manually.`);
98
+ }
99
+
100
+ result.changed = result.added.length > 0 || result.migrated.length > 0 || result.removed.length > 0;
101
+ // When sync:eds belongs to the consumer, point at the CLI instead of their script.
102
+ const syncRunCommand = result.conflicts.includes(syncScriptName) ? "npx eds sync" : runnerCommand(packageManager, syncScriptName);
103
+ if (result.changed && dryRun) {
104
+ log("[EVA Design System] would update package.json scripts: " + [...result.added, ...result.migrated, ...result.removed].join(", "));
105
+ } else if (result.changed) {
106
+ consumerPackage.scripts = scripts;
107
+ writeConsumerPackageJson(packagePath, source, consumerPackage);
108
+ log("[EVA Design System] Added consumer setup scripts. Run " + syncRunCommand + ".");
109
+ } else if (result.conflicts.length === 0) {
110
+ log("[EVA Design System] Consumer setup scripts are already registered. Run " + syncRunCommand + ".");
111
+ } else {
112
+ log("[EVA Design System] Consumer setup scripts were left unchanged. Run " + syncRunCommand + ".");
113
+ }
114
+
115
+ // Yarn berry (2+) does not run pre/post lifecycle hooks, so the doctor gate must be inlined by the consumer.
116
+ if (packageManager?.name === "yarn" && packageManager.berry && hookTargets.length > 0) {
117
+ log(
118
+ "[EVA Design System] Yarn 2+ does not run pretypecheck/prebuild hooks. Prepend \"" + doctorCommand +
119
+ " && \" to your " + hookTargets.join(" and ") + " script" + (hookTargets.length > 1 ? "s" : "") +
120
+ " to keep the doctor gate (this tool does not edit them).",
121
+ );
122
+ }
123
+ return result;
124
+ }
@@ -0,0 +1,70 @@
1
+ import { relativeTo } from "./paths.mjs";
2
+
3
+ // Tailwind utilities that take a color. A class such as hover:bg-eds-bg-brand-primary/80 is one token here.
4
+ const colorUtility = "(?:text|bg|border|ring|outline|fill|stroke|decoration|divide|from|to|via|accent|caret|shadow|placeholder|inset-ring)";
5
+ const variantPrefix = "(?:[a-z0-9-]+:)*";
6
+ const boundaryBefore = "(?<![\\w-])";
7
+ const boundaryAfter = "(?![\\w-])";
8
+ const opacity = "(?:/[0-9]{1,3})?";
9
+
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");
12
+ const rawColorClass = new RegExp(
13
+ boundaryBefore + variantPrefix + colorUtility +
14
+ "-(?:(?: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\\()[^\\]]*\\])" +
15
+ opacity + boundaryAfter,
16
+ "g",
17
+ );
18
+
19
+ // The public class names are derived exactly like eds sync derives the Tailwind aliases from tokens.json.
20
+ export function publicClassNames(manifest) {
21
+ const colors = new Set();
22
+ const typography = new Set();
23
+ for (const name of Object.keys(manifest.tokens ?? {})) {
24
+ if (name.startsWith("color-")) {
25
+ colors.add("eds-" + name.slice("color-".length));
26
+ continue;
27
+ }
28
+ const match = name.match(/^typography-(.+)-(?:fontFamily|fontWeight|fontSize|lineHeight|letterSpacing)$/);
29
+ if (match) {
30
+ typography.add(match[1]);
31
+ }
32
+ }
33
+ return { colors, typography };
34
+ }
35
+
36
+ // files: [{ path, contents }] as readSources() lists them (the generated eds-tokens.css is not among them).
37
+ // unknown: eds-* classes that no public token backs (always a mistake, Tailwind silently drops them).
38
+ // raw: Tailwind palette, white/black and arbitrary color classes that bypass the EDS tokens (informational).
39
+ export function scanClassNames({ consumerRoot, manifest, files }) {
40
+ const { colors, typography } = publicClassNames(manifest);
41
+ const unknown = [];
42
+ const raw = [];
43
+ for (const file of files) {
44
+ const relativePath = relativeTo(consumerRoot, file.path);
45
+ file.contents.split(/\r?\n/).forEach((line, index) => {
46
+ const location = relativePath + ":" + (index + 1);
47
+ for (const match of line.matchAll(edsColorClass)) {
48
+ // "bg-eds-bg-" + name is composed at runtime; the scan cannot judge it.
49
+ if (match[1].endsWith("-")) {
50
+ continue;
51
+ }
52
+ if (!colors.has("eds-" + match[1])) {
53
+ unknown.push({ location, className: match[0] });
54
+ }
55
+ }
56
+ for (const match of line.matchAll(edsTypographyClass)) {
57
+ if (match[1].endsWith("-")) {
58
+ continue;
59
+ }
60
+ if (!typography.has(match[1])) {
61
+ unknown.push({ location, className: match[0] });
62
+ }
63
+ }
64
+ for (const match of line.matchAll(rawColorClass)) {
65
+ raw.push({ location, className: match[0] });
66
+ }
67
+ });
68
+ }
69
+ return { unknown, raw };
70
+ }
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ consumerConfigField,
5
+ findTailwindRoot,
6
+ planCssRootImports,
7
+ planLegacyJsImportCleanup,
8
+ readSources,
9
+ tailwindRootConfigKey,
10
+ tailwindRootEnvironmentVariable,
11
+ } from "./css-root.mjs";
12
+ 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";
16
+
17
+ export const syncTargets = new Set(["tailwind", "agents"]);
18
+ 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
+
23
+ export function collectPlans({ consumerRoot, env = process.env }) {
24
+ const pkg = resolvePackage(consumerRoot);
25
+ const packageManager = detectPackageManager(consumerRoot, env);
26
+ const sources = readSources(consumerRoot);
27
+ const root = findTailwindRoot(consumerRoot, env, sources, packageManager);
28
+ // No Tailwind root: the Tailwind wiring is skipped entirely (no generated CSS, no root edits).
29
+ const tailwind = root === null ? null : planTailwind({ consumerRoot, pkg });
30
+ const cssRoot = root === null ? null : planCssRootImports({ consumerRoot, root, sources });
31
+ const legacyImports = root === null ? [] : planLegacyJsImportCleanup({ consumerRoot });
32
+ const errors = [];
33
+ let agents = null;
34
+ try {
35
+ agents = planAgentContext({ consumerRoot, pkg, packageManager });
36
+ } catch (error) {
37
+ errors.push(error);
38
+ }
39
+ const claude = planClaudeImport({ consumerRoot });
40
+ const gemini = planGeminiImport({ consumerRoot });
41
+ return { consumerRoot, pkg, packageManager, sources, root, tailwind, cssRoot, legacyImports, agents, claude, gemini, errors };
42
+ }
43
+
44
+ export function planSync({ consumerRoot, env = process.env, only = null }) {
45
+ if (only !== null && !syncTargets.has(only)) {
46
+ throw createExitError("Unknown --only target: " + only + " (use tailwind or agents).", 2);
47
+ }
48
+ const plans = collectPlans({ consumerRoot, env });
49
+ if (plans.errors.length > 0) {
50
+ throw plans.errors[0];
51
+ }
52
+
53
+ const writes = [];
54
+ if ((only === null || only === "tailwind") && plans.root !== null) {
55
+ writes.push(plans.tailwind, plans.cssRoot, ...plans.legacyImports);
56
+ }
57
+ if (only === null || only === "agents") {
58
+ writes.push(plans.agents);
59
+ if (plans.claude) {
60
+ writes.push(plans.claude);
61
+ }
62
+ if (plans.gemini) {
63
+ writes.push(plans.gemini);
64
+ }
65
+ }
66
+ // Like doctor, an EOL-only difference (an autocrlf checkout of the LF generated CSS) is already synced.
67
+ return { ...plans, only, writes: writes.filter((write) => normalizeEol(write.before) !== normalizeEol(write.after)) };
68
+ }
69
+
70
+ // EDS_TAILWIND_ROOT is a one-time choice: sync records it as package.json "eds": { "tailwindRoot" } so
71
+ // doctor and the pretypecheck/prebuild hooks resolve the same root without the environment variable.
72
+ function persistTailwindRoot(plan, { dryRun, log }) {
73
+ if (plan.root?.source !== "env" || plan.only === "agents") {
74
+ return false;
75
+ }
76
+ const packagePath = path.join(plan.consumerRoot, "package.json");
77
+ const source = fs.readFileSync(packagePath, "utf8");
78
+ const consumerPackage = JSON.parse(source);
79
+ const existing = consumerPackage[consumerConfigField];
80
+ 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.");
82
+ return false;
83
+ }
84
+ const value = plan.root.relativePath;
85
+ const label = consumerConfigField + "." + tailwindRootConfigKey + " = " + JSON.stringify(value);
86
+ if (existing?.[tailwindRootConfigKey] === value) {
87
+ return false;
88
+ }
89
+ if (dryRun) {
90
+ log("[EDS] would write " + label + " to package.json");
91
+ return false;
92
+ }
93
+ consumerPackage[consumerConfigField] = { ...(existing ?? {}), [tailwindRootConfigKey]: value };
94
+ writeConsumerPackageJson(packagePath, source, consumerPackage);
95
+ log("[EDS] wrote " + label + " to package.json; doctor, sync and the hooks now use this Tailwind root without " + tailwindRootEnvironmentVariable + ".");
96
+ return true;
97
+ }
98
+
99
+ export function applySync(plan, { dryRun = false, log = console.log } = {}) {
100
+ const version = "@spectrabrain/eds@" + plan.pkg.version;
101
+ if (plan.root === null && plan.only !== "agents") {
102
+ log("[EDS] " + noTailwindNote);
103
+ } else if (plan.root?.warning) {
104
+ log("[EDS] " + plan.root.warning);
105
+ }
106
+ persistTailwindRoot(plan, { dryRun, log });
107
+ if (plan.writes.length === 0) {
108
+ log("[EDS] Already synced with " + version + ".");
109
+ return [];
110
+ }
111
+ for (const write of plan.writes) {
112
+ if (dryRun) {
113
+ log("[EDS] would write " + write.relativePath);
114
+ continue;
115
+ }
116
+ fs.mkdirSync(path.dirname(write.path), { recursive: true });
117
+ fs.writeFileSync(write.path, write.after);
118
+ log("[EDS] wrote " + write.relativePath);
119
+ }
120
+ 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
+ );
125
+ }
126
+ return plan.writes.map((write) => write.relativePath);
127
+ }
128
+
129
+ export function runSync({ consumerRoot, env = process.env, only = null, dryRun = false, log = console.log }) {
130
+ const plan = planSync({ consumerRoot, env, only });
131
+ applySync(plan, { dryRun, log });
132
+ return 0;
133
+ }