@imfusion/web-ui 0.5.1-dev.28.g1156ad00 → 0.5.1-dev.30.gf9b152db

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/bin/install.js CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  symlinkSync,
31
31
  writeFileSync
32
32
  } from "node:fs";
33
+ import { spawnSync } from "node:child_process";
33
34
  import { dirname, relative, resolve } from "node:path";
34
35
  import { fileURLToPath } from "node:url";
35
36
  import * as p from "@clack/prompts";
@@ -136,6 +137,19 @@ const hooksSettingsTemplate = resolve(skillsRoot, "imf-web-ui-agent-setup", "tem
136
137
  const hooksDestDir = resolve(projectRoot, ".agents", "hooks", "imf-web-ui");
137
138
  const settingsPath = resolve(projectRoot, ".claude", "settings.json");
138
139
 
140
+ // Serializing the file reflows entries this installer doesn't own; the
141
+ // consumer's own Prettier puts them back. Best-effort: absent, or the path is
142
+ // .prettierignore'd, nothing happens.
143
+ function formatSettings() {
144
+ let toolRoot = projectRoot;
145
+ while (toolRoot !== dirname(toolRoot) && !existsSync(resolve(toolRoot, "node_modules", ".bin"))) {
146
+ toolRoot = dirname(toolRoot);
147
+ }
148
+ const prettier = resolve(toolRoot, "node_modules", ".bin", "prettier");
149
+ if (!existsSync(prettier)) return;
150
+ spawnSync(prettier, ["--write", settingsPath], { cwd: projectRoot, stdio: "ignore" });
151
+ }
152
+
139
153
  function installHookScripts() {
140
154
  const scripts = readdirSync(hooksSourceDir).filter(name => name.endsWith(".sh"));
141
155
  mkdirSync(hooksDestDir, { recursive: true });
@@ -147,9 +161,24 @@ function installHookScripts() {
147
161
  return scripts.length;
148
162
  }
149
163
 
164
+ // A registration is identified by the script it runs, not the command string:
165
+ // "$VAR/..." and "${VAR}/..." are the same hook.
166
+ const HOOKS_DIR_SEGMENT = ".agents/hooks/imf-web-ui/";
167
+
168
+ function hookScriptId(command) {
169
+ if (typeof command !== "string") return null;
170
+ const at = command.lastIndexOf(HOOKS_DIR_SEGMENT);
171
+ if (at === -1) return null;
172
+ return command.slice(at + HOOKS_DIR_SEGMENT.length).replace(/["'\s]+$/, "");
173
+ }
174
+
175
+ function entryScriptIds(entry) {
176
+ return (entry.hooks ?? []).map(hook => hookScriptId(hook.command)).filter(Boolean);
177
+ }
178
+
150
179
  // Merge, never clobber: a registration is added only when no existing entry
151
- // for that event already runs the same command, so re-runs are idempotent
152
- // and hand-written settings survive.
180
+ // for that event already runs the same script, so re-runs are idempotent and
181
+ // hand-written settings survive. A run that adds nothing writes nothing.
153
182
  function mergeHookRegistrations() {
154
183
  const template = JSON.parse(readFileSync(hooksSettingsTemplate, "utf-8"));
155
184
  let settings = {};
@@ -165,9 +194,9 @@ function mergeHookRegistrations() {
165
194
  for (const [event, entries] of Object.entries(template.hooks)) {
166
195
  settings.hooks[event] ??= [];
167
196
  for (const entry of entries) {
168
- const commands = entry.hooks.map(hook => hook.command);
197
+ const scripts = entryScriptIds(entry);
169
198
  const present = settings.hooks[event].some(existing =>
170
- (existing.hooks ?? []).some(hook => commands.includes(hook.command))
199
+ entryScriptIds(existing).some(script => scripts.includes(script))
171
200
  );
172
201
  if (!present) {
173
202
  settings.hooks[event].push(entry);
@@ -175,8 +204,10 @@ function mergeHookRegistrations() {
175
204
  }
176
205
  }
177
206
  }
207
+ if (added === 0) return 0;
178
208
  mkdirSync(dirname(settingsPath), { recursive: true });
179
209
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
210
+ formatSettings();
180
211
  return added;
181
212
  }
182
213
 
@@ -27,7 +27,7 @@ function runInstall(cwd: string, ...args: string[]) {
27
27
  function readSettings(cwd: string) {
28
28
  return JSON.parse(readFileSync(join(cwd, ".claude", "settings.json"), "utf-8")) as {
29
29
  permissions?: { allow: string[] };
30
- hooks: Record<string, { hooks: { command: string }[] }[]>;
30
+ hooks: Record<string, { hooks: { command?: string }[] }[]>;
31
31
  };
32
32
  }
33
33
 
@@ -103,7 +103,53 @@ describe("web-ui-install", () => {
103
103
  const commands = settings.hooks.PostToolUse.flatMap(entry => entry.hooks.map(hook => hook.command));
104
104
  expect(commands).toContain("echo existing");
105
105
  // the shipped registration is added exactly once across both runs
106
- expect(commands.filter(command => command.includes("post-tool-use.sh"))).toHaveLength(1);
106
+ expect(commands.filter(command => command?.includes("post-tool-use.sh"))).toHaveLength(1);
107
+ });
108
+
109
+ it("recognises registrations written with braced ${CLAUDE_PROJECT_DIR}", () => {
110
+ mkdirSync(join(consumer, ".claude"), { recursive: true });
111
+ writeFileSync(
112
+ join(consumer, ".claude", "settings.json"),
113
+ JSON.stringify({
114
+ hooks: {
115
+ PostToolUse: [
116
+ {
117
+ matcher: "Edit|MultiEdit|Write",
118
+ hooks: [
119
+ {
120
+ type: "command",
121
+ command: '"${CLAUDE_PROJECT_DIR}/.agents/hooks/imf-web-ui/post-tool-use.sh"',
122
+ statusMessage: "Verifying file"
123
+ }
124
+ ]
125
+ },
126
+ // a foreign entry carrying no `command` at all must not throw
127
+ { matcher: "Bash", hooks: [{ type: "command", args: ["${CLAUDE_PROJECT_DIR}/other/gate.mjs"] }] }
128
+ ]
129
+ }
130
+ })
131
+ );
132
+
133
+ runInstall(consumer, "--hooks");
134
+
135
+ const settings = readSettings(consumer);
136
+ const commands = settings.hooks.PostToolUse.flatMap(entry => entry.hooks.map(hook => hook.command));
137
+ expect(commands.filter(command => command?.includes("post-tool-use.sh"))).toHaveLength(1);
138
+ expect(commands).toContain('"${CLAUDE_PROJECT_DIR}/.agents/hooks/imf-web-ui/post-tool-use.sh"');
139
+ });
140
+
141
+ it("does not touch settings.json when every registration is already present", () => {
142
+ runInstall(consumer, "--hooks");
143
+ const settingsFile = join(consumer, ".claude", "settings.json");
144
+ const settings = readSettings(consumer);
145
+ settings.hooks.PreToolUse = [{ hooks: [{ command: "./other/gate.mjs" }] }];
146
+ const before = JSON.stringify(settings, null, 4) + "\n";
147
+ writeFileSync(settingsFile, before);
148
+
149
+ const result = runInstall(consumer, "--hooks");
150
+
151
+ expect(readFileSync(settingsFile, "utf-8")).toBe(before);
152
+ expect(result.stdout).toContain("Hook registrations already present");
107
153
  });
108
154
 
109
155
  it("upserts the fenced AGENTS.md block and leaves the rest untouched", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imfusion/web-ui",
3
- "version": "0.5.1-dev.28.g1156ad00",
3
+ "version": "0.5.1-dev.30.gf9b152db",
4
4
  "description": "The official Web UI component library for ImFusion web apps",
5
5
  "author": "ImFusion GmbH",
6
6
  "license": "UNLICENSED",
@@ -61,7 +61,7 @@
61
61
  "codegen:llms": "tsx src/llms/gen-llms.ts",
62
62
  "verify:format": "prettier --check .",
63
63
  "verify:lint": "eslint . --cache --max-warnings=0",
64
- "verify:typecheck": "npm run codegen && tsc -p tsconfig.lib.json --noEmit && tsc -p .storybook/tsconfig.json --noEmit",
64
+ "verify:typecheck": "npm run codegen && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.lib.json --noEmit && tsc -p .storybook/tsconfig.json --noEmit",
65
65
  "verify:tests": "vitest run",
66
66
  "verify:deps": "tsx scripts/check-pinned-dependencies.ts",
67
67
  "verify:changelog": "tsx scripts/verify/changelog.ts",