@spectrabrain/eds 0.4.0 → 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.
@@ -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
  }
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "specVersion": 1,
3
- "packageVersion": "0.4.0",
3
+ "packageVersion": "0.6.0",
4
4
  "name": "Button",
5
5
  "purpose": "사용자가 즉시 실행할 수 있는 행동과 탐색을 표현하는 인터랙티브 요소입니다.",
6
6
  "usageContexts": [