@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.
- package/AI.md +13 -63
- package/AI_CONTEXT.json +26 -95
- package/CHANGELOG.md +66 -4
- package/CONSUMER_AGENT.md +86 -164
- package/FRONTEND_CONVENTIONS.md +252 -0
- package/QUICKSTART.md +24 -29
- package/README.md +24 -73
- package/TAILWIND_V4.md +49 -184
- package/TOKEN_CONTEXT.md +63 -36
- package/consumer/align/templates/components.json +21 -0
- package/consumer/align/templates/env.mock +1 -0
- package/consumer/align/templates/eslint.config.js +35 -0
- package/consumer/align/templates/package.scripts.json +10 -0
- package/consumer/align/templates/prettierignore +2 -0
- package/consumer/align/templates/prettierrc.json +7 -0
- package/consumer/align/templates/src/app/App.tsx +16 -0
- package/consumer/align/templates/src/app/main.tsx +27 -0
- package/consumer/align/templates/src/app/providers/ErrorBoundary.tsx +58 -0
- package/consumer/align/templates/src/app/providers/ErrorHandler.tsx +29 -0
- package/consumer/align/templates/src/app/router/index.tsx +23 -0
- package/consumer/align/templates/src/app/styles/index.css +2 -0
- package/consumer/align/templates/src/mocks/browser.ts +5 -0
- package/consumer/align/templates/src/mocks/handlers.ts +4 -0
- package/consumer/align/templates/src/shared/api/index.ts +23 -0
- package/consumer/align/templates/src/shared/api/queryClient.ts +24 -0
- package/consumer/align/templates/src/shared/lib/eventBus.ts +44 -0
- package/consumer/align/templates/src/shared/lib/utils/index.ts +6 -0
- package/consumer/align/templates/src/vite-env.d.ts +2 -0
- package/consumer/align/templates/tsconfig.json +22 -0
- package/consumer/align/templates/vite.config.babel.ts +36 -0
- package/consumer/align/templates/vite.config.rolldown.ts +35 -0
- package/consumer/align-analysis.mjs +1159 -0
- package/consumer/align-plan.mjs +377 -0
- package/consumer/align-rules.mjs +473 -0
- package/consumer/align.mjs +401 -0
- package/consumer/cli.mjs +90 -40
- package/consumer/css-root.mjs +24 -4
- package/consumer/doctor.mjs +47 -21
- package/consumer/init.mjs +39 -25
- package/consumer/managed-block.mjs +11 -5
- package/consumer/paths.mjs +69 -14
- package/consumer/register-scripts.mjs +17 -12
- package/consumer/scan-classes.mjs +4 -3
- package/consumer/sync.mjs +35 -13
- package/consumer/update-agent-context.mjs +63 -7
- package/consumer-postinstall.mjs +8 -7
- package/dist/components/Button/README.md +66 -95
- package/dist/components/Button/spec.json +19 -138
- package/dist/components.css +102 -366
- package/dist/index.d.mts +20 -18
- package/dist/index.d.ts +20 -18
- package/dist/tokens.d.mts +3 -0
- package/dist/tokens.d.ts +3 -0
- package/llms.txt +20 -14
- package/package.json +3 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
analyzeFrontend,
|
|
7
|
+
checkBackendPackage,
|
|
8
|
+
classifyChangedPaths,
|
|
9
|
+
declaredDependencies,
|
|
10
|
+
detectFramework,
|
|
11
|
+
findFrontendPackages,
|
|
12
|
+
hashFile,
|
|
13
|
+
parseJsonc,
|
|
14
|
+
summarizeStructure,
|
|
15
|
+
walkFiles,
|
|
16
|
+
} from "./align-analysis.mjs";
|
|
17
|
+
import { countFindings, packageManagerCommands, renderNotes, renderPlan } from "./align-plan.mjs";
|
|
18
|
+
import { alignDirectory, conventionsFileName, planFileName, rootLockfiles, stateFileName, stateSchemaVersion, templatesDirectoryName } from "./align-rules.mjs";
|
|
19
|
+
import { cliCommand, createExitError, detectPackageManager, generatedCssRelativePath, installCommand, packageName, readPackageJson, resolvePackage, toPosix } from "./paths.mjs";
|
|
20
|
+
|
|
21
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
|
+
const templatesRoot = path.join(packageRoot, "consumer", "align", "templates");
|
|
23
|
+
const notesFileName = "NOTES.md";
|
|
24
|
+
const findingLimitPerKind = 40;
|
|
25
|
+
const placeholderPattern = /\{\{eds\.(\w+)\}\}/g;
|
|
26
|
+
|
|
27
|
+
function git(cwd, args) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gitPaths(cwd, args) {
|
|
36
|
+
return (git(cwd, args) ?? "").split("\0").filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readState(statePath, shown = (relativePath) => relativePath) {
|
|
40
|
+
if (!fs.existsSync(statePath)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
let state;
|
|
44
|
+
try {
|
|
45
|
+
state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw createExitError(toPosix(statePath) + " could not be read (" + error.message + "); delete " + shown(alignDirectory) + " and run eds align again.", 2);
|
|
48
|
+
}
|
|
49
|
+
if (state?.schemaVersion !== stateSchemaVersion) {
|
|
50
|
+
throw createExitError(toPosix(statePath) + " was written by another eds align version; delete " + shown(alignDirectory) + " and run eds align again.", 2);
|
|
51
|
+
}
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The frontend package: --frontend, the current directory when it holds a plan or declares react, or the only
|
|
56
|
+
// React package below it.
|
|
57
|
+
function resolveFrontendDirectory(consumerRoot, frontendOption) {
|
|
58
|
+
if (frontendOption) {
|
|
59
|
+
const directory = path.resolve(consumerRoot, frontendOption);
|
|
60
|
+
if (!fs.existsSync(path.join(directory, "package.json"))) {
|
|
61
|
+
throw createExitError("--frontend " + frontendOption + " has no package.json.", 2);
|
|
62
|
+
}
|
|
63
|
+
return directory;
|
|
64
|
+
}
|
|
65
|
+
if (fs.existsSync(path.join(consumerRoot, alignDirectory, stateFileName))) {
|
|
66
|
+
return consumerRoot;
|
|
67
|
+
}
|
|
68
|
+
const own = readPackageJson(consumerRoot);
|
|
69
|
+
if (own !== null && declaredDependencies(own).has("react")) {
|
|
70
|
+
return consumerRoot;
|
|
71
|
+
}
|
|
72
|
+
const candidates = findFrontendPackages(consumerRoot).filter((candidate) => candidate !== ".");
|
|
73
|
+
if (candidates.length === 1) {
|
|
74
|
+
return path.join(consumerRoot, candidates[0]);
|
|
75
|
+
}
|
|
76
|
+
throw createExitError(
|
|
77
|
+
candidates.length === 0
|
|
78
|
+
? "No package here or below declares react; run eds align inside the frontend package."
|
|
79
|
+
: "Several React packages (" + candidates.join(", ") + "); rerun with --frontend <dir>.",
|
|
80
|
+
2,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Tracked changes against a commit plus untracked files, as repository-relative paths.
|
|
85
|
+
function changedSince(gitRoot, commit) {
|
|
86
|
+
return [
|
|
87
|
+
...gitPaths(gitRoot, ["diff", "--name-only", "-z", "--no-renames", commit]),
|
|
88
|
+
...gitPaths(gitRoot, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readStrictOption(packageDirectory) {
|
|
93
|
+
for (const name of ["tsconfig.json", "tsconfig.app.json"]) {
|
|
94
|
+
const filePath = path.join(packageDirectory, name);
|
|
95
|
+
if (!fs.existsSync(filePath)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
if (parseJsonc(fs.readFileSync(filePath, "utf8")).compilerOptions?.strict === true) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// An unparsable tsconfig is reported by the check; the template keeps the EVA default.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function joinPosix(...parts) {
|
|
110
|
+
const joined = path.posix.join(...parts.filter((part) => part !== "" && part !== "."));
|
|
111
|
+
return joined === "" ? "." : joined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Templates rendered with this repository's paths. Keys are target paths relative to the frontend package.
|
|
115
|
+
export function renderTemplates({ app, protectedInside, packageJson, viteMajor, strict }) {
|
|
116
|
+
const eslintFileName = packageJson.type === "module" ? "eslint.config.js" : "eslint.config.mjs";
|
|
117
|
+
const viteTemplate = viteMajor !== null && viteMajor >= 8 ? "vite.config.rolldown.ts" : "vite.config.babel.ts";
|
|
118
|
+
const values = {
|
|
119
|
+
srcDir: app.srcDir,
|
|
120
|
+
strict: String(strict),
|
|
121
|
+
eslintIgnores: ["dist", "coverage", ".eds", ...protectedInside].map((entry) => JSON.stringify(entry)).join(", "),
|
|
122
|
+
prettierIgnores: [
|
|
123
|
+
"dist",
|
|
124
|
+
"coverage",
|
|
125
|
+
".eds",
|
|
126
|
+
joinPosix(app.appDir, "public/mockServiceWorker.js"),
|
|
127
|
+
toPosix(generatedCssRelativePath),
|
|
128
|
+
"*.md",
|
|
129
|
+
...rootLockfiles,
|
|
130
|
+
...protectedInside,
|
|
131
|
+
].join("\n"),
|
|
132
|
+
};
|
|
133
|
+
const rendered = new Map();
|
|
134
|
+
for (const templatePath of walkFiles(templatesRoot)) {
|
|
135
|
+
let target;
|
|
136
|
+
if (templatePath === "vite.config.babel.ts" || templatePath === "vite.config.rolldown.ts") {
|
|
137
|
+
if (templatePath !== viteTemplate) continue;
|
|
138
|
+
target = "vite.config.ts";
|
|
139
|
+
} else if (templatePath === "eslint.config.js") {
|
|
140
|
+
target = eslintFileName;
|
|
141
|
+
} else if (templatePath === "prettierrc.json" || templatePath === "prettierignore") {
|
|
142
|
+
target = "." + templatePath;
|
|
143
|
+
} else if (templatePath === "env.mock") {
|
|
144
|
+
target = joinPosix(app.appDir, ".env.mock");
|
|
145
|
+
} else if (templatePath.startsWith("src/")) {
|
|
146
|
+
target = joinPosix(app.srcDir, templatePath.slice("src/".length));
|
|
147
|
+
} else if (templatePath.startsWith("public/")) {
|
|
148
|
+
target = joinPosix(app.appDir, templatePath);
|
|
149
|
+
} else {
|
|
150
|
+
target = templatePath;
|
|
151
|
+
}
|
|
152
|
+
const contents = fs.readFileSync(path.join(templatesRoot, templatePath), "utf8").replace(placeholderPattern, (match, name) => {
|
|
153
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
154
|
+
throw new Error("Unknown template placeholder " + match + " in " + templatePath);
|
|
155
|
+
}
|
|
156
|
+
return values[name];
|
|
157
|
+
});
|
|
158
|
+
rendered.set(target, contents);
|
|
159
|
+
}
|
|
160
|
+
return rendered;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function logFindings(findings, log) {
|
|
164
|
+
const shown = new Map();
|
|
165
|
+
const hidden = new Map();
|
|
166
|
+
for (const finding of findings) {
|
|
167
|
+
const key = finding.level + " " + finding.kind;
|
|
168
|
+
const count = shown.get(key) ?? 0;
|
|
169
|
+
if (count >= findingLimitPerKind) {
|
|
170
|
+
hidden.set(key, (hidden.get(key) ?? 0) + 1);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
shown.set(key, count + 1);
|
|
174
|
+
log("[EDS] " + (finding.level === "warning" ? "warning " : "") + finding.kind + ": " + finding.message);
|
|
175
|
+
}
|
|
176
|
+
for (const [key, count] of hidden) {
|
|
177
|
+
log("[EDS] … " + count + " more " + key + " findings (fix the ones above and run the check again)");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The rules file as an agent reaches it from the frontend package: through node_modules (the frontend's own or a hoisted
|
|
182
|
+
// one up to the git root) when EDS is installed there, else the resolved package directory (pnpm store, linked install).
|
|
183
|
+
function conventionsPathFrom(packageDirectory, gitRoot, packageRoot) {
|
|
184
|
+
for (let directory = packageDirectory; ; directory = path.dirname(directory)) {
|
|
185
|
+
const candidate = path.join(directory, "node_modules", packageName, conventionsFileName);
|
|
186
|
+
if (fs.existsSync(candidate)) {
|
|
187
|
+
return toPosix(path.relative(packageDirectory, candidate));
|
|
188
|
+
}
|
|
189
|
+
if (directory === gitRoot || path.dirname(directory) === directory) {
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return toPosix(path.join(path.relative(packageDirectory, packageRoot), conventionsFileName));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function verificationCommands(commands) {
|
|
197
|
+
return ["format:check", "lint", "typecheck", "build", "eds:doctor"].map((script) => commands.run(script)).join(", ");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function locateFrontend({ consumerRoot, frontend }) {
|
|
201
|
+
const packageDirectory = fs.realpathSync(resolveFrontendDirectory(consumerRoot, frontend));
|
|
202
|
+
const packageJson = readPackageJson(packageDirectory);
|
|
203
|
+
const framework = detectFramework(declaredDependencies(packageJson));
|
|
204
|
+
if (framework.unsupported !== null) {
|
|
205
|
+
throw createExitError(
|
|
206
|
+
framework.unsupported + " found: moving it to Vite is a framework migration, not alignment. eds align supports Vite + React single-page apps.",
|
|
207
|
+
2,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (!framework.vite || !framework.react) {
|
|
211
|
+
throw createExitError("eds align needs a Vite + React app; " + toPosix(path.join(packageDirectory, "package.json")) + " must declare vite and react.", 2);
|
|
212
|
+
}
|
|
213
|
+
const topLevel = git(packageDirectory, ["rev-parse", "--show-toplevel"]);
|
|
214
|
+
const gitRoot = topLevel === null ? null : fs.realpathSync(topLevel.trim());
|
|
215
|
+
const packageRelative = gitRoot === null ? "" : toPosix(path.relative(gitRoot, packageDirectory));
|
|
216
|
+
return { packageDirectory, packageJson, gitRoot, packageRelative };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function requireGit(gitRoot) {
|
|
220
|
+
if (gitRoot === null) {
|
|
221
|
+
throw createExitError(
|
|
222
|
+
"eds align needs git: the base commit is how --check proves the backend did not change. Ask the user to create a git repository at " +
|
|
223
|
+
"the project root (the directory that holds both frontend and backend) with a .gitignore covering node_modules, build output and " +
|
|
224
|
+
".env files, commit everything once, then rerun eds align.",
|
|
225
|
+
2,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function runAlign({ consumerRoot, env = process.env, check = false, frontend = null, dryRun = false, log = console.log }) {
|
|
231
|
+
const located = locateFrontend({ consumerRoot, frontend });
|
|
232
|
+
// Paths in the output are relative to the current directory, which differs from the frontend package with --frontend.
|
|
233
|
+
const fromCwd = toPosix(path.relative(consumerRoot, located.packageDirectory));
|
|
234
|
+
const shown = (relativePath) => (fromCwd === "" ? relativePath : fromCwd + "/" + relativePath);
|
|
235
|
+
// With --frontend the rerun hint keeps the flag, or the check could not locate the package again.
|
|
236
|
+
const frontendFlag = frontend ? " --frontend " + frontend : "";
|
|
237
|
+
return check ? runCheck({ ...located, env, log, shown, frontendFlag }) : runPlan({ ...located, env, dryRun, log, fromCwd, shown });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function runPlan({ packageDirectory, packageJson, gitRoot, packageRelative, env, dryRun, log, fromCwd, shown }) {
|
|
241
|
+
requireGit(gitRoot);
|
|
242
|
+
let pkg;
|
|
243
|
+
try {
|
|
244
|
+
pkg = resolvePackage(packageDirectory);
|
|
245
|
+
} catch {
|
|
246
|
+
throw createExitError("Install " + packageName + " in " + (packageRelative || ".") + " first (" + installCommand(detectPackageManager(packageDirectory, env)) + "), then run eds align there.", 2);
|
|
247
|
+
}
|
|
248
|
+
if (fs.realpathSync(pkg.packageRoot) !== fs.realpathSync(packageRoot)) {
|
|
249
|
+
log("[EDS] note: this is eds from " + toPosix(packageRoot) + ", but the frontend has " + packageName + "@" + pkg.version + " installed; run its own CLI (" + cliCommand(detectPackageManager(packageDirectory, env), "align") + ") so the plan matches the installed documents.");
|
|
250
|
+
}
|
|
251
|
+
const packageManager = detectPackageManager(packageDirectory, env);
|
|
252
|
+
const alignPath = path.join(packageDirectory, alignDirectory);
|
|
253
|
+
const alignRelative = packageRelative === "" ? alignDirectory : packageRelative + "/" + alignDirectory;
|
|
254
|
+
const previous = readState(path.join(alignPath, stateFileName), shown);
|
|
255
|
+
|
|
256
|
+
const analysis = analyzeFrontend({ packageDirectory, gitRoot, protectedInfo: previous?.protected ?? null });
|
|
257
|
+
let base;
|
|
258
|
+
let baseline;
|
|
259
|
+
if (previous !== null) {
|
|
260
|
+
base = previous.git.base;
|
|
261
|
+
baseline = previous.git.baseline;
|
|
262
|
+
} else {
|
|
263
|
+
base = git(gitRoot, ["rev-parse", "--verify", "HEAD"])?.trim() ?? null;
|
|
264
|
+
if (!base) {
|
|
265
|
+
throw createExitError("The repository has no commit yet, so --check has no base. Ask the user to commit the current files once " +
|
|
266
|
+
"(with a .gitignore covering node_modules, build output and .env files), then rerun eds align.", 2);
|
|
267
|
+
}
|
|
268
|
+
// Uncommitted changes that already exist are the baseline. Only paths the check guards are recorded:
|
|
269
|
+
// everything else inside the frontend package may change freely.
|
|
270
|
+
baseline = {};
|
|
271
|
+
const guarded = (relativePath) =>
|
|
272
|
+
analysis.protectedInfo.repository.some((entry) => relativePath === entry || relativePath.startsWith(entry + "/")) ||
|
|
273
|
+
(packageRelative !== "" && relativePath !== packageRelative && !relativePath.startsWith(packageRelative + "/"));
|
|
274
|
+
for (const relativePath of changedSince(gitRoot, "HEAD")) {
|
|
275
|
+
if (relativePath !== alignRelative && !relativePath.startsWith(alignRelative + "/") && guarded(relativePath)) {
|
|
276
|
+
baseline[relativePath] = hashFile(path.join(gitRoot, relativePath));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
analysis.structure = summarizeStructure(analysis.allFiles);
|
|
282
|
+
const templates = renderTemplates({
|
|
283
|
+
app: analysis.app,
|
|
284
|
+
protectedInside: analysis.protectedInfo.inside,
|
|
285
|
+
packageJson,
|
|
286
|
+
viteMajor: analysis.majors.vite,
|
|
287
|
+
strict: readStrictOption(packageDirectory),
|
|
288
|
+
});
|
|
289
|
+
const createdAt = previous?.createdAt ?? new Date().toISOString();
|
|
290
|
+
const frontendState = { packageRelative, appDir: analysis.app.appDir, srcDir: analysis.app.srcDir };
|
|
291
|
+
const state = {
|
|
292
|
+
schemaVersion: stateSchemaVersion,
|
|
293
|
+
createdBy: packageName + "@" + pkg.version,
|
|
294
|
+
createdAt,
|
|
295
|
+
frontend: frontendState,
|
|
296
|
+
git: { base, baseline },
|
|
297
|
+
protected: analysis.protectedInfo,
|
|
298
|
+
};
|
|
299
|
+
const plan = renderPlan({
|
|
300
|
+
version: pkg.version,
|
|
301
|
+
createdAt,
|
|
302
|
+
base,
|
|
303
|
+
packageManager,
|
|
304
|
+
frontend: frontendState,
|
|
305
|
+
analysis,
|
|
306
|
+
templates: [...templates.keys()],
|
|
307
|
+
alignLabel: alignDirectory,
|
|
308
|
+
conventionsPath: conventionsPathFrom(packageDirectory, gitRoot, pkg.packageRoot),
|
|
309
|
+
});
|
|
310
|
+
const counts = countFindings(analysis.findings);
|
|
311
|
+
|
|
312
|
+
log("[EDS] align: frontend " + (packageRelative || ".") + " (Vite " + (analysis.majors.vite ?? "?") + ", React " + (analysis.majors.react ?? "?") +
|
|
313
|
+
", " + packageManager.name + "), sources " + analysis.app.srcDir);
|
|
314
|
+
const protectedList = analysis.protectedInfo.repository;
|
|
315
|
+
log("[EDS] align: protected paths: " + (protectedList.length > 0 ? protectedList.join(", ") : "none detected") +
|
|
316
|
+
(packageRelative === "" ? "" : ", plus everything outside " + packageRelative));
|
|
317
|
+
log("[EDS] align: " + counts.errors + " " + plural(counts.errors, "error") + (counts.errorSummary ? " (" + counts.errorSummary + ")" : "") + ", " +
|
|
318
|
+
counts.warnings + " " + plural(counts.warnings, "warning") + " to resolve");
|
|
319
|
+
if (previous !== null) {
|
|
320
|
+
log("[EDS] align: kept the base commit " + base.slice(0, 12) + " from " + createdAt.slice(0, 10) + "; delete " + shown(alignDirectory) + " to start over.");
|
|
321
|
+
}
|
|
322
|
+
const planPath = path.join(alignPath, planFileName);
|
|
323
|
+
if (dryRun) {
|
|
324
|
+
const notesExist = fs.existsSync(path.join(alignPath, notesFileName));
|
|
325
|
+
log("[EDS] would write " + shown(alignDirectory + "/" + planFileName) + ", " + (notesExist ? "" : shown(alignDirectory + "/" + notesFileName) + ", ") +
|
|
326
|
+
shown(alignDirectory + "/" + stateFileName) + " and " + templates.size + " templates in " + shown(alignDirectory + "/" + templatesDirectoryName));
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const templatesPath = path.join(alignPath, templatesDirectoryName);
|
|
331
|
+
fs.rmSync(templatesPath, { recursive: true, force: true });
|
|
332
|
+
fs.mkdirSync(alignPath, { recursive: true });
|
|
333
|
+
// The directory ignores itself, so the plan never lands in a commit or in the base comparison.
|
|
334
|
+
fs.writeFileSync(path.join(alignPath, ".gitignore"), "*\n");
|
|
335
|
+
fs.writeFileSync(path.join(alignPath, stateFileName), JSON.stringify(state, null, 2) + "\n");
|
|
336
|
+
fs.writeFileSync(planPath, plan);
|
|
337
|
+
const notesPath = path.join(alignPath, notesFileName);
|
|
338
|
+
if (!fs.existsSync(notesPath)) {
|
|
339
|
+
fs.writeFileSync(notesPath, renderNotes());
|
|
340
|
+
}
|
|
341
|
+
for (const [target, contents] of templates) {
|
|
342
|
+
const targetPath = path.join(templatesPath, target);
|
|
343
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
344
|
+
fs.writeFileSync(targetPath, contents);
|
|
345
|
+
}
|
|
346
|
+
const commands = packageManagerCommands(packageManager);
|
|
347
|
+
log("[EDS] wrote " + shown(alignDirectory + "/" + planFileName) + ", " + notesFileName + ", " + stateFileName + " and " + templates.size + " templates (base " + base.slice(0, 12) + ")");
|
|
348
|
+
log("[EDS] Next: " + (fromCwd === "" ? "" : "cd " + fromCwd + ", then ") + "follow " + alignDirectory + "/" + planFileName + " step by step; " +
|
|
349
|
+
commands.eds("align --check") + " verifies each step" + (fromCwd === "" ? "." : " (run every PLAN.md command from " + fromCwd + ")."));
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// With a plan, the check also proves that no protected or out-of-package file changed since its base commit.
|
|
354
|
+
// Without one (after step 8 removed .eds/align), it checks the conventions only.
|
|
355
|
+
function runCheck({ packageDirectory, gitRoot, packageRelative, env, log, shown, frontendFlag = "" }) {
|
|
356
|
+
const state = readState(path.join(packageDirectory, alignDirectory, stateFileName), shown);
|
|
357
|
+
const commands = packageManagerCommands(detectPackageManager(packageDirectory, env));
|
|
358
|
+
const findings = [];
|
|
359
|
+
let analysis;
|
|
360
|
+
if (state === null) {
|
|
361
|
+
log("[EDS] note: no " + shown(alignDirectory + "/" + stateFileName) + ": checking the conventions only (the protected-path check needs the base commit of eds align).");
|
|
362
|
+
analysis = analyzeFrontend({ packageDirectory, gitRoot: gitRoot ?? packageDirectory });
|
|
363
|
+
} else {
|
|
364
|
+
requireGit(gitRoot);
|
|
365
|
+
if (git(gitRoot, ["cat-file", "-e", state.git.base + "^{commit}"]) === null) {
|
|
366
|
+
throw createExitError("The base commit " + state.git.base.slice(0, 12) + " is not in this repository; delete " + shown(alignDirectory) + " and run eds align again.", 2);
|
|
367
|
+
}
|
|
368
|
+
const alignRelative = packageRelative === "" ? alignDirectory : packageRelative + "/" + alignDirectory;
|
|
369
|
+
findings.push(...classifyChangedPaths({
|
|
370
|
+
changed: changedSince(gitRoot, state.git.base),
|
|
371
|
+
baseline: state.git.baseline ?? {},
|
|
372
|
+
hashOf: (relativePath) => hashFile(path.join(gitRoot, relativePath)),
|
|
373
|
+
packageRelative,
|
|
374
|
+
protectedPaths: state.protected.repository ?? [],
|
|
375
|
+
alignRelative,
|
|
376
|
+
base: state.git.base,
|
|
377
|
+
}));
|
|
378
|
+
analysis = analyzeFrontend({ packageDirectory, gitRoot, protectedInfo: state.protected });
|
|
379
|
+
findings.push(...checkBackendPackage({ packageJson: analysis.packageJson, state }));
|
|
380
|
+
}
|
|
381
|
+
findings.push(...analysis.findings);
|
|
382
|
+
const counts = countFindings(findings);
|
|
383
|
+
const since = state === null ? "" : " since " + state.git.base.slice(0, 12);
|
|
384
|
+
logFindings(findings, log);
|
|
385
|
+
if (counts.errors === 0) {
|
|
386
|
+
log("[EDS] align: " + analysis.app.srcDir + " follows the EVA frontend conventions" +
|
|
387
|
+
(state === null ? "" : ", and no protected or out-of-package file changed" + since) +
|
|
388
|
+
(counts.warnings > 0 ? " (" + counts.warnings + " " + plural(counts.warnings, "warning") + ": fix them or record why)" : "") + ".");
|
|
389
|
+
log("[EDS] Also required: " + verificationCommands(commands) + " (each must exit 0, and eds:doctor must print [EDS] synced with)" +
|
|
390
|
+
(state === null ? "." : ", then step 8 of " + shown(alignDirectory + "/" + planFileName) + "."));
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
393
|
+
log("[EDS] align: " + counts.errors + " " + plural(counts.errors, "error") + " (" + counts.errorSummary + "), " + counts.warnings + " " +
|
|
394
|
+
plural(counts.warnings, "warning") + since + ". Fix the errors and run " + commands.eds("align --check" + frontendFlag) + " again" +
|
|
395
|
+
(state === null ? "." : "; " + shown(alignDirectory + "/" + planFileName) + " maps each kind to a step, and protected, outside and backend-* errors mean revert that change."));
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function plural(count, word) {
|
|
400
|
+
return count === 1 ? word : word + "s";
|
|
401
|
+
}
|
package/consumer/cli.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { runAlign } from "./align.mjs";
|
|
2
3
|
import { runDoctor } from "./doctor.mjs";
|
|
3
4
|
import { runInit } from "./init.mjs";
|
|
4
5
|
import { runSync } from "./sync.mjs";
|
|
@@ -7,21 +8,27 @@ const usage = [
|
|
|
7
8
|
"Usage: eds <command> [options]",
|
|
8
9
|
"",
|
|
9
10
|
"Commands:",
|
|
10
|
-
" init Register sync:eds
|
|
11
|
-
"
|
|
11
|
+
" init Register sync:eds and eds:doctor, plus pretypecheck/prebuild (eds doctor) when typecheck/build exist,",
|
|
12
|
+
" write allowScripts on npm 12+, then run sync. Run it in the package that declares @spectrabrain/eds",
|
|
12
13
|
" sync Generate src/styles/eds-tokens.css, wire the Tailwind root CSS and update AGENTS.md",
|
|
13
|
-
" and the @AGENTS.md import in CLAUDE.md and GEMINI.md when they exist
|
|
14
|
+
" and the @AGENTS.md import in CLAUDE.md (or .claude/CLAUDE.md) and GEMINI.md when they exist",
|
|
15
|
+
" (without a Tailwind root only those change); notes say when an agent may not load the block",
|
|
14
16
|
" doctor Check whether sync is up to date, the EDS font is imported and every eds-* class in the sources",
|
|
15
17
|
" is a public token (exit 1 when something is off)",
|
|
18
|
+
" align One-time reorganization of a Vite + React frontend into the EVA conventions (FRONTEND_CONVENTIONS.md):",
|
|
19
|
+
" write .eds/align/PLAN.md, the steps an AI agent follows, plus templates; source files are not changed",
|
|
16
20
|
"",
|
|
17
21
|
"Options:",
|
|
18
22
|
" --only tailwind|agents sync: apply only that part",
|
|
19
|
-
" --dry-run Print the files sync or
|
|
23
|
+
" --dry-run Print the files sync, init or align would write without writing them",
|
|
20
24
|
" --no-sync init: register scripts only",
|
|
21
25
|
" --no-allow-scripts init: do not write allowScripts to package.json on npm 12+",
|
|
22
|
-
" --print-ci github|gitlab init: print a CI job that runs
|
|
26
|
+
" --print-ci github|gitlab init: print a CI job that runs eds doctor with this repository's package manager",
|
|
23
27
|
" --warn doctor: report findings but exit 0 (same as EDS_DOCTOR=warn)",
|
|
24
28
|
" --scan doctor: also list Tailwind palette and arbitrary color classes that bypass the EDS tokens",
|
|
29
|
+
" --check align: check the conventions and that no backend or out-of-package file changed since",
|
|
30
|
+
" the plan's base commit (exit 1 when something is off)",
|
|
31
|
+
" --frontend <dir> align: the frontend package when the current directory is not it",
|
|
25
32
|
" --help Show this message",
|
|
26
33
|
"",
|
|
27
34
|
"Environment:",
|
|
@@ -30,55 +37,97 @@ const usage = [
|
|
|
30
37
|
" EDS_DOCTOR=warn|off Downgrade or silence doctor failures",
|
|
31
38
|
].join("\n");
|
|
32
39
|
|
|
40
|
+
const commands = ["init", "sync", "doctor", "align"];
|
|
41
|
+
// The commands each option applies to. An option given to another command stops the CLI instead of being ignored,
|
|
42
|
+
// so `eds sync --check` never writes files while looking like a check.
|
|
43
|
+
const optionCommands = {
|
|
44
|
+
"--only": ["sync"],
|
|
45
|
+
"--dry-run": ["sync", "init", "align"],
|
|
46
|
+
"--no-sync": ["init"],
|
|
47
|
+
"--no-allow-scripts": ["init"],
|
|
48
|
+
"--print-ci": ["init"],
|
|
49
|
+
"--warn": ["doctor"],
|
|
50
|
+
"--scan": ["doctor"],
|
|
51
|
+
"--check": ["align"],
|
|
52
|
+
"--frontend": ["align"],
|
|
53
|
+
};
|
|
54
|
+
const valueOptions = new Set(["--only", "--print-ci", "--frontend"]);
|
|
55
|
+
|
|
56
|
+
function usageError(message) {
|
|
57
|
+
// The bare `npx eds` could fetch an unrelated registry package, so the hint names the scoped package.
|
|
58
|
+
const error = new Error(message + " Run npx @spectrabrain/eds --help (or your package runner's eds --help) for the commands and options.");
|
|
59
|
+
error.exitCode = 2;
|
|
60
|
+
return error;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
function parseArguments(argv) {
|
|
34
|
-
const [
|
|
35
|
-
const
|
|
64
|
+
const [first, ...rest] = argv;
|
|
65
|
+
const command = first === "help" ? "--help" : first;
|
|
66
|
+
const options = {
|
|
67
|
+
only: null,
|
|
68
|
+
dryRun: false,
|
|
69
|
+
noSync: false,
|
|
70
|
+
allowScripts: true,
|
|
71
|
+
printCi: null,
|
|
72
|
+
warn: false,
|
|
73
|
+
scan: false,
|
|
74
|
+
check: false,
|
|
75
|
+
frontend: null,
|
|
76
|
+
help: false,
|
|
77
|
+
};
|
|
36
78
|
for (let index = 0; index < rest.length; index += 1) {
|
|
37
79
|
const argument = rest[index];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
options.allowScripts = false;
|
|
44
|
-
} else if (argument === "--warn") {
|
|
45
|
-
options.warn = true;
|
|
46
|
-
} else if (argument === "--scan") {
|
|
47
|
-
options.scan = true;
|
|
48
|
-
} else if (argument === "--help" || argument === "-h") {
|
|
80
|
+
// pnpm and yarn pass the `--` of `pnpm eds:doctor -- --scan` through to the CLI.
|
|
81
|
+
if (argument === "--") {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (argument === "--help" || argument === "-h") {
|
|
49
85
|
options.help = true;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const [name, inlineValue] = argument.startsWith("--") && argument.includes("=")
|
|
89
|
+
? [argument.slice(0, argument.indexOf("=")), argument.slice(argument.indexOf("=") + 1)]
|
|
90
|
+
: [argument, null];
|
|
91
|
+
if (optionCommands[name] === undefined) {
|
|
92
|
+
throw usageError("Unknown option: " + argument + ".");
|
|
93
|
+
}
|
|
94
|
+
if (commands.includes(command) && !optionCommands[name].includes(command)) {
|
|
95
|
+
throw usageError(name + " is not an option of eds " + command + " (it applies to " + optionCommands[name].join(", ") + ").");
|
|
96
|
+
}
|
|
97
|
+
let value = inlineValue;
|
|
98
|
+
if (valueOptions.has(name) && value === null) {
|
|
56
99
|
index += 1;
|
|
57
|
-
|
|
58
|
-
} else if (argument.startsWith("--print-ci=")) {
|
|
59
|
-
options.printCi = argument.slice("--print-ci=".length);
|
|
60
|
-
} else {
|
|
61
|
-
throw new Error("Unknown option: " + argument);
|
|
100
|
+
value = rest[index] ?? "";
|
|
62
101
|
}
|
|
102
|
+
// A value option given nothing, or followed by another option, would silently read that option as its value.
|
|
103
|
+
if (valueOptions.has(name) && (value === "" || value.startsWith("--"))) {
|
|
104
|
+
throw usageError(name + " needs a value.");
|
|
105
|
+
}
|
|
106
|
+
if (name === "--only") options.only = value;
|
|
107
|
+
else if (name === "--print-ci") options.printCi = value;
|
|
108
|
+
else if (name === "--frontend") options.frontend = value;
|
|
109
|
+
else if (name === "--dry-run") options.dryRun = true;
|
|
110
|
+
else if (name === "--no-sync") options.noSync = true;
|
|
111
|
+
else if (name === "--no-allow-scripts") options.allowScripts = false;
|
|
112
|
+
else if (name === "--warn") options.warn = true;
|
|
113
|
+
else if (name === "--scan") options.scan = true;
|
|
114
|
+
else if (name === "--check") options.check = true;
|
|
63
115
|
}
|
|
64
116
|
return { command, options };
|
|
65
117
|
}
|
|
66
118
|
|
|
67
119
|
async function main() {
|
|
68
120
|
const consumerRoot = process.cwd();
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
parsed = parseArguments(process.argv.slice(2));
|
|
72
|
-
} catch (error) {
|
|
73
|
-
console.error(error.message + "\n\n" + usage);
|
|
74
|
-
return 2;
|
|
75
|
-
}
|
|
76
|
-
const { command, options } = parsed;
|
|
121
|
+
const { command, options } = parseArguments(process.argv.slice(2));
|
|
77
122
|
|
|
78
123
|
if (command === "--help" || command === "-h" || options.help) {
|
|
79
124
|
console.log(usage);
|
|
80
125
|
return 0;
|
|
81
126
|
}
|
|
127
|
+
if (command === undefined) {
|
|
128
|
+
console.error("[EDS] Missing command (" + commands.join(", ") + ").\n\n" + usage);
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
82
131
|
if (command === "sync") {
|
|
83
132
|
return runSync({ consumerRoot, only: options.only, dryRun: options.dryRun });
|
|
84
133
|
}
|
|
@@ -94,9 +143,10 @@ async function main() {
|
|
|
94
143
|
printCi: options.printCi,
|
|
95
144
|
});
|
|
96
145
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
if (command === "align") {
|
|
147
|
+
return runAlign({ consumerRoot, check: options.check, frontend: options.frontend, dryRun: options.dryRun });
|
|
148
|
+
}
|
|
149
|
+
throw usageError("Unknown command: " + command + " (use " + commands.join(", ") + ").");
|
|
100
150
|
}
|
|
101
151
|
|
|
102
152
|
try {
|
package/consumer/css-root.mjs
CHANGED
|
@@ -28,6 +28,8 @@ const tailwindSpecifier = "tailwindcss(?:\\/(?:theme|utilities)(?:\\.css)?)?";
|
|
|
28
28
|
const tailwindImportSource = "^[ \\t]*@import\\s+(?:url\\(\\s*)?[\"']" + tailwindSpecifier + "[\"']\\s*\\)?[^;]*;[^\\r\\n]*";
|
|
29
29
|
const tailwindImportPattern = new RegExp(tailwindImportSource, "m");
|
|
30
30
|
const tailwindImportPatternGlobal = new RegExp(tailwindImportSource, "gm");
|
|
31
|
+
// Tailwind CSS v3 roots use @tailwind directives instead of @import "tailwindcss"; EDS wiring needs v4.
|
|
32
|
+
const tailwindV3DirectivePattern = /^[ \t]*@tailwind\s+(?:base|components|utilities|variants|screens)\b/m;
|
|
31
33
|
const cssImportPattern = /^[ \t]*@import\s+(?:url\()?["']([^"']+)["']/gm;
|
|
32
34
|
const scriptImportPattern = /^[ \t]*import\s+["']([^"']+)["']/gm;
|
|
33
35
|
const legacyGeneratedImportPattern = /^[ \t]*import\s+["'][^"']*eds-tokens\.css["'];?[ \t]*(?:\r?\n|$)/gm;
|
|
@@ -49,6 +51,19 @@ function hasTailwindImport(contents) {
|
|
|
49
51
|
return tailwindImportPattern.test(stripCssComments(contents));
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function hasTailwindV3Directive(contents) {
|
|
55
|
+
return tailwindV3DirectivePattern.test(stripCssComments(contents));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const tailwindV3Advice = "EDS Tailwind wiring needs Tailwind CSS v4. Import @spectrabrain/eds/styles.css in the app entry and use " +
|
|
59
|
+
"var(--eds-*) or cssVar() for tokens; ask the user before upgrading to Tailwind CSS v4.";
|
|
60
|
+
|
|
61
|
+
// The first CSS file that still uses Tailwind CSS v3 @tailwind directives, or null.
|
|
62
|
+
export function findTailwindV3File(consumerRoot, sources) {
|
|
63
|
+
const file = sources.css.find((candidate) => hasTailwindV3Directive(candidate.contents) && !hasTailwindImport(candidate.contents));
|
|
64
|
+
return file ? relativeTo(consumerRoot, file.path) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
function readSources(consumerRoot) {
|
|
53
68
|
const generatedPath = path.join(consumerRoot, generatedCssRelativePath);
|
|
54
69
|
const css = listCssFiles(consumerRoot)
|
|
@@ -79,6 +94,12 @@ function resolveExplicitRoot(consumerRoot, relative, { source, label, remedy })
|
|
|
79
94
|
}
|
|
80
95
|
const contents = fs.readFileSync(rootPath, "utf8");
|
|
81
96
|
const relativePath = relativeTo(consumerRoot, rootPath);
|
|
97
|
+
if (hasTailwindV3Directive(contents) && !hasTailwindImport(contents)) {
|
|
98
|
+
throw createExitError(
|
|
99
|
+
label + " points to " + relativePath + ", which uses Tailwind CSS v3 @tailwind directives. Remove that setting. " + tailwindV3Advice,
|
|
100
|
+
2,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
82
103
|
return {
|
|
83
104
|
path: rootPath,
|
|
84
105
|
relativePath,
|
|
@@ -126,10 +147,9 @@ export function findTailwindRoot(
|
|
|
126
147
|
const found = candidates.map((file) => relativeTo(consumerRoot, file.path));
|
|
127
148
|
throw createExitError(
|
|
128
149
|
"Could not identify one Tailwind root CSS file (found " + found.length + ": " + found.join(", ") + "). " +
|
|
129
|
-
"
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
"\": \"src/index.css\" } so doctor and the hooks reuse it.",
|
|
150
|
+
"Ask the user which of these is the app's Tailwind root, then run " + tailwindRootEnvironmentVariable + "=<that file> " +
|
|
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.",
|
|
133
153
|
2,
|
|
134
154
|
);
|
|
135
155
|
}
|