@fusion.dev/helix-cli 0.206.0 → 0.207.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.
package/README.md CHANGED
@@ -10,8 +10,10 @@ npm install --save-dev @fusion.dev/helix-cli
10
10
 
11
11
  The command is a native binary, and npm installs the build for your machine
12
12
  beside this package: macOS and Linux on Arm and x86-64, and Windows on x86-64.
13
- An install that skips optional dependencies skips it too, and `helix` then names
14
- the package it's missing. To start a project rather than add Helix to one, use
13
+ An install that skips optional dependencies skips it too. `helix` then runs a
14
+ `helix` on your `PATH` whose `helix --version --json` names this package, and
15
+ otherwise names the package that is missing and each `helix` it did not run,
16
+ such as the Helix text editor. To start a new project with Helix, use
15
17
  `npm create @fusion.dev/helix-app`.
16
18
 
17
19
  ```sh
@@ -24,10 +26,11 @@ helix mcp init --client claude
24
26
  |---|---|
25
27
  | `component` | Checks the components you build on Helix's base classes: registration under your prefix, imports, slots, parts, states, hooks, words, reflection, attribute names, lifecycle, and listeners. Reads the folders named by `tiers` in `helix.json`. `--only <rule>` runs some of them |
26
28
  | `theme` | `init` a starter intent, `build` it to CSS, `audit` every contrast pair, `check` in CI |
27
- | `new` | Scaffolds a project from a Helix template |
29
+ | `new` | Scaffolds a project from a Helix template: `helix new my-app --template react-vite`. The templates install with the CLI, in `@fusion.dev/helix-templates`, so `npx @fusion.dev/helix-cli new` works from an empty directory. Omit `--template` to list them |
28
30
  | `diagnostics` | Receives the runs `initDiagnostics()` sends from your app and prints each new finding. `--once <url>` opens a page headless instead, diagnoses it, and exits. `--out <path>` writes the CI file, `--port` picks the port, 7417 by default, and `--config <path>` names the settings served to the page |
29
31
  | `scan` | Reads your project's stylesheets, templates, and modules, and prints each finding with its file and line. `--build <dir>` weighs each Helix family in your bundle, `--out <path>` writes the run to the CI file, `--config <path>` names the settings, `--file <path>` reports one file's findings, and `--stdin <language>` checks a snippet |
30
32
  | `api` | Looks up the installed Helix packages: `find` a family, a `family`'s API, an `example` with its imports, the `imports` for a list of tags, and `tokens` |
33
+ | `manifest` | `diff` compares two custom elements manifests and prints each difference. Exits 1 on any difference |
31
34
  | `visual` | Pictures each page in a real browser and compares it with a baseline kept in git: `test`, `update` the baselines, open the `report`, and `compare` two PNG files. Reads `visual.config.ts`, and needs Playwright installed. [The guide](https://github.com/project-fusion/fusion/blob/main/helix/cli/npm/visual/README.md) has the config and the GitHub Action |
32
35
  | `mcp` | Serves the same lookups, the scan, and the theme derivation to an agent over stdio, as an MCP server. `mcp init --client <client>` writes the configuration for Claude Code, Cursor, VS Code, Gemini CLI, opencode, Roo Code, or Junie |
33
36
 
@@ -40,6 +43,8 @@ Color is already off when stdout is not a terminal and when `NO_COLOR` is set.
40
43
  rather than compiled into the binary, so one build serves every release that
41
44
  changes no Rust. A binary built from a checkout of this repository has no such
42
45
  package beside it and says `0.0.0-dev`, which is what that build is.
46
+ `helix --version --json` prints the same release as
47
+ `{ "name": "helix", "package": "@fusion.dev/helix-cli", "version": … }`.
43
48
 
44
49
  [Documentation](https://fusion.dev/helix)
45
50
 
package/bin.mjs CHANGED
@@ -13,15 +13,17 @@
13
13
  * builds one if it can, and says exactly what is missing if it cannot.
14
14
  *
15
15
  * **Resolution order, and each step is there for a reason.** `HELIX_BIN` first,
16
- * because a caller that knows better should not have to argue. Then the cargo
17
- * target directory, release before debug, honoring `CARGO_TARGET_DIR` because a
18
- * shared target is common and guessing `./target` would miss it. Then the
19
- * binary npm installed beside this, which is what a consumer has: after the
20
- * target, so a checkout runs what it built rather than a binary a release once
21
- * placed. Then whatever is on `PATH`, named out loud in a checkout, because a
22
- * binary from somewhere else is about to answer questions about this tree. Only
23
- * then a build, and only in this repository, the one place there is a crate to
24
- * build.
16
+ * because a caller that knows better should not have to argue. Then, in a
17
+ * checkout, the cargo target directory, release before debug, honoring
18
+ * `CARGO_TARGET_DIR` because a shared target is common and guessing `./target`
19
+ * would miss it. Then the binary npm installed beside this, which is what a
20
+ * consumer has: after the target, so a checkout runs what it built rather than
21
+ * a binary a release once placed. A consumer's target is never read: a `helix`
22
+ * there is another program or an old build of ours, and the installed one is
23
+ * the release they asked for. Then whatever is on `PATH` that says it is this
24
+ * command line, named out loud in a checkout, because a binary from somewhere
25
+ * else is about to answer questions about this tree. Only then a build, and
26
+ * only in this repository, the one place there is a crate to build.
25
27
  *
26
28
  * **A build is a fallback and not the plan.** It happens once, prints that it
27
29
  * is happening, and a CI pipeline that has already run `cargo build` never
@@ -97,7 +99,7 @@ const hasCargo = () => spawnSync('cargo', ['--version'], { stdio: 'ignore' }).st
97
99
  const SELF = realpathSync(fileURLToPath(import.meta.url))
98
100
 
99
101
  /**
100
- * The first `helix` on `PATH`, resolved through its symlinks, or undefined.
102
+ * Each `helix` on `PATH`, in order, resolved through its symlinks.
101
103
  *
102
104
  * **Walked rather than spawned, because the spawn is the bug.** Probing with
103
105
  * `spawnSync('helix', ['--version'])` asks `PATH` to answer, and under
@@ -112,8 +114,9 @@ const SELF = realpathSync(fileURLToPath(import.meta.url))
112
114
  * trap is set for a fresh clone, which is what CI and a new contributor both
113
115
  * are.
114
116
  */
115
- const onPath = () => {
117
+ function* onPath() {
116
118
  const extensions = process.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']
119
+ const seen = new Set()
117
120
  for (const directory of (process.env.PATH ?? '').split(delimiter)) {
118
121
  if (directory === '') {
119
122
  continue
@@ -136,13 +139,47 @@ const onPath = () => {
136
139
  says nothing about a real one later in it, and returning here would
137
140
  make an installed binary invisible whenever a checkout was in front of
138
141
  it. */
139
- if (real === SELF) {
142
+ if (real === SELF || seen.has(real)) {
140
143
  continue
141
144
  }
142
- return real
145
+ seen.add(real)
146
+ yield real
143
147
  }
144
148
  }
145
- return undefined
149
+ }
150
+
151
+ /**
152
+ * Set while this shim runs a `PATH` entry with `--version --json`, so a copy
153
+ * of this shim run that way uses only the binary it has installed and never
154
+ * searches `PATH` itself: two copies with no binary between them would
155
+ * otherwise run each other without end.
156
+ */
157
+ const PROBE = 'HELIX_SHIM_PROBE'
158
+
159
+ /**
160
+ * Whether `binary` is this command line, read from what it prints for
161
+ * `--version --json`.
162
+ *
163
+ * **The name proves nothing.** On Arch Linux `helix` is the Helix text editor,
164
+ * and an old `cargo install` of this command line has none of the commands a
165
+ * project runs now, such as `scan`. This command line prints its package's
166
+ * name for `--version --json`. The editor exits with an error on `--json`, and
167
+ * an older build prints its plain version line. Measured 10 Oct 2026 on an
168
+ * Apple silicon laptop: 4 ms for this command line, and 2 ms for an old build.
169
+ * The timeout ends a program that never exits.
170
+ */
171
+ const ours = (binary) => {
172
+ const asked = spawnSync(binary, ['--version', '--json'], {
173
+ encoding: 'utf8',
174
+ env: { ...process.env, [PROBE]: '1' },
175
+ stdio: ['ignore', 'pipe', 'ignore'],
176
+ timeout: 2_000,
177
+ })
178
+ try {
179
+ return JSON.parse(asked.stdout ?? '').package === '@fusion.dev/helix-cli'
180
+ } catch {
181
+ return false
182
+ }
146
183
  }
147
184
 
148
185
  /**
@@ -157,6 +194,27 @@ const DRIVER_ENV = {
157
194
  HELIX_VISUAL_RUNTIME: process.env.HELIX_VISUAL_RUNTIME ?? process.execPath,
158
195
  }
159
196
 
197
+ /**
198
+ * What `helix new` reads: the `@fusion.dev/helix-templates` this package
199
+ * depends on, at this package's version, named in `HELIX_TEMPLATES` where the
200
+ * caller has not named another.
201
+ *
202
+ * **Resolved here, because node resolves a dependency the way it was
203
+ * installed.** The binary walks up from where it runs for a `node_modules`
204
+ * holding the templates, and `npx @fusion.dev/helix-cli new` runs from a
205
+ * directory with none: npm installed the command and its dependencies in its
206
+ * own cache. Under pnpm, or beside another version of the templates in a
207
+ * project, that walk misses this package's copy as well.
208
+ */
209
+ try {
210
+ DRIVER_ENV.HELIX_TEMPLATES ??= dirname(
211
+ createRequire(import.meta.url).resolve('@fusion.dev/helix-templates/templates.json'),
212
+ )
213
+ } catch {
214
+ /* Not installed, so the binary walks up for the templates and names the
215
+ package when nothing has it. */
216
+ }
217
+
160
218
  const run = (binary) => {
161
219
  const { status, error } = spawnSync(binary, process.argv.slice(2), {
162
220
  env: DRIVER_ENV,
@@ -183,8 +241,14 @@ const run = (binary) => {
183
241
  features per selection, so alternating `-p fusion-helix-cli` with `--workspace`
184
242
  recompiled on every switch, about a second each, where asking for the same
185
243
  selection twice is 0.14s. `HELIX_BIN` still outranks everything, and a checkout
186
- with no cargo falls through to whatever is already built. */
187
- if (process.env.HELIX_BIN === undefined && checkout && hasCargo()) {
244
+ with no cargo falls through to whatever is already built, as a probe from
245
+ another copy of this shim does: it waits two seconds for an answer. */
246
+ if (
247
+ process.env.HELIX_BIN === undefined &&
248
+ process.env[PROBE] === undefined &&
249
+ checkout &&
250
+ hasCargo()
251
+ ) {
188
252
  const fresh = spawnSync('cargo', ['build', '--workspace', '--quiet'], {
189
253
  cwd: WORKSPACE,
190
254
  stdio: 'inherit',
@@ -199,14 +263,24 @@ if (process.env.HELIX_BIN === undefined && checkout && hasCargo()) {
199
263
  }
200
264
  }
201
265
 
202
- const found = process.env.HELIX_BIN ?? built() ?? packaged()
266
+ const found = process.env.HELIX_BIN ?? (checkout ? built() : undefined) ?? packaged()
203
267
  if (found && existsSync(found)) {
204
268
  run(found)
205
269
  }
206
270
 
207
- /* `PATH`, which is what an installed binary looks like. Resolved rather than
208
- probed: see `onPath`, where the probe this used to do is what recursed. */
209
- const installed = onPath()
271
+ /* `PATH`, which is what an installed binary looks like, walked rather than
272
+ searched by a spawn: see `onPath`, where a spawn of `helix` recursed. Each
273
+ one found runs only once `ours` says it is this command line, and the
274
+ others are named below, where the run gives up. */
275
+ const skipped = []
276
+ let installed
277
+ for (const candidate of process.env[PROBE] === undefined ? onPath() : []) {
278
+ if (ours(candidate)) {
279
+ installed = candidate
280
+ break
281
+ }
282
+ skipped.push(candidate)
283
+ }
210
284
  if (installed) {
211
285
  /* **In a checkout, say which binary this is before running it.** Getting here
212
286
  means no `target/`, no platform package and no cargo, so what runs is
@@ -231,6 +305,21 @@ if (installed) {
231
305
  run(installed)
232
306
  }
233
307
 
308
+ /** The `helix` binaries on `PATH` that are some other program, named. */
309
+ const foreign = () => {
310
+ if (skipped.length === 0) {
311
+ return ''
312
+ }
313
+ const one = skipped.length === 1
314
+ return (
315
+ `\n\n Not run, from PATH: ${skipped.map((path) => `\`${path}\``).join(', ')}.\n` +
316
+ ` ${one ? 'Its output' : 'The output of each'} for \`--version --json\` does not name\n` +
317
+ ` \`@fusion.dev/helix-cli\`, so ${one ? 'it is' : 'each is'} another program named helix, such\n` +
318
+ ' as the Helix text editor, or a build of this command line from before that\n' +
319
+ ' output.'
320
+ )
321
+ }
322
+
234
323
  /* Installed from the registry, with no platform package for this machine. A
235
324
  build would be `cargo build` in somebody else's project, for a crate that is
236
325
  not in it. */
@@ -241,7 +330,7 @@ if (!checkout) {
241
330
  ' `@fusion.dev/helix-cli`, and it is not installed. Either this machine is not\n' +
242
331
  ' one there is a build for, or the install skipped optional dependencies\n' +
243
332
  ' (`--omit=optional`, `--no-optional`), in which case install again without it.\n' +
244
- ' `HELIX_BIN` names a binary to run instead.',
333
+ ` \`HELIX_BIN\` names a binary to run instead.${foreign()}`,
245
334
  )
246
335
  process.exit(69)
247
336
  }
@@ -252,7 +341,7 @@ if (!hasCargo()) {
252
341
  ` Looked at: $HELIX_BIN, ${join(target, 'release', NAME)},\n` +
253
342
  ` ${join(target, 'debug', NAME)}, and \`${NAME}\` on PATH.\n\n` +
254
343
  ' Either install the binary, point `HELIX_BIN` at one, or install a Rust\n' +
255
- ' toolchain so this can build it.',
344
+ ` toolchain so this can build it.${foreign()}`,
256
345
  )
257
346
  process.exit(69)
258
347
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-cli",
3
3
  "description": "The Helix command line, for a workspace that reaches it through npm, and the visual tests it runs.",
4
- "version": "0.206.0",
4
+ "version": "0.207.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -29,6 +29,9 @@
29
29
  "test": "bun test"
30
30
  },
31
31
  "bugs": "https://github.com/project-fusion/fusion/issues",
32
+ "dependencies": {
33
+ "@fusion.dev/helix-templates": "0.207.0"
34
+ },
32
35
  "homepage": "https://fusion.dev",
33
36
  "keywords": [
34
37
  "helix",
@@ -41,11 +44,11 @@
41
44
  "visual-regression"
42
45
  ],
43
46
  "optionalDependencies": {
44
- "@fusion.dev/helix-cli-darwin-arm64": "0.206.0",
45
- "@fusion.dev/helix-cli-darwin-x64": "0.206.0",
46
- "@fusion.dev/helix-cli-linux-arm64": "0.206.0",
47
- "@fusion.dev/helix-cli-linux-x64": "0.206.0",
48
- "@fusion.dev/helix-cli-win32-x64": "0.206.0"
47
+ "@fusion.dev/helix-cli-darwin-arm64": "0.207.0",
48
+ "@fusion.dev/helix-cli-darwin-x64": "0.207.0",
49
+ "@fusion.dev/helix-cli-linux-arm64": "0.207.0",
50
+ "@fusion.dev/helix-cli-linux-x64": "0.207.0",
51
+ "@fusion.dev/helix-cli-win32-x64": "0.207.0"
49
52
  },
50
53
  "peerDependencies": {
51
54
  "@anthropic-ai/sdk": ">=0.132",
package/visual/driver.mjs CHANGED
@@ -230,19 +230,34 @@ const respond = (request, response) => {
230
230
  *
231
231
  * **Bounded, because `whenDefined` on a tag nothing registers never settles.**
232
232
  * Unbounded, a page naming an element the bundle does not define hangs the
233
- * run with no output. This gives up after five seconds and names the tags.
233
+ * run with no output. This gives up after {@link UPGRADES} seconds and names
234
+ * the tags.
234
235
  */
235
236
  const upgrades = (page, prefix) =>
236
- page.evaluate(async (prefix) => {
237
- const pending = [
238
- ...new Set([...document.querySelectorAll(':not(:defined)')].map((one) => one.localName)),
239
- ].filter((tag) => tag.startsWith(prefix))
240
- await Promise.race([
241
- Promise.all(pending.map((tag) => customElements.whenDefined(tag))),
242
- new Promise((done) => setTimeout(done, 5000)),
243
- ])
244
- return pending.filter((tag) => customElements.get(tag) === undefined)
245
- }, prefix ?? '')
237
+ page.evaluate(
238
+ async ({ prefix, seconds }) => {
239
+ const pending = [
240
+ ...new Set([...document.querySelectorAll(':not(:defined)')].map((one) => one.localName)),
241
+ ].filter((tag) => tag.startsWith(prefix))
242
+ await Promise.race([
243
+ Promise.all(pending.map((tag) => customElements.whenDefined(tag))),
244
+ new Promise((done) => setTimeout(done, seconds * 1000)),
245
+ ])
246
+ return pending.filter((tag) => customElements.get(tag) === undefined)
247
+ },
248
+ { prefix: prefix ?? '', seconds: UPGRADES },
249
+ )
250
+
251
+ /**
252
+ * **How long a page has to define its elements after it loads: twenty
253
+ * seconds**, a third of what one shot may take. The bound only stops a run
254
+ * hanging on a tag nothing registers, and a page that registers late is slow,
255
+ * which a picture does not judge. Five seconds failed the site's theme builder
256
+ * on two CI runs in a row on October 10, 2026: its first style pass over
257
+ * 15,638 elements and Helix's chunk took 1.7 seconds on an M-series Mac, and a
258
+ * shared runner takes several times that.
259
+ */
260
+ const UPGRADES = 20
246
261
 
247
262
  /**
248
263
  * Waits for the page to hold still: three frames, the fonts, each element's
@@ -424,7 +439,7 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
424
439
  const missing = await upgrades(page, elements)
425
440
  if (missing.length > 0) {
426
441
  throw new Error(
427
- `nothing defines ${missing.map((tag) => `<${tag}>`).join(', ')}, after five seconds of waiting`,
442
+ `nothing defines ${missing.map((tag) => `<${tag}>`).join(', ')}, after ${UPGRADES} seconds of waiting`,
428
443
  )
429
444
  }
430
445
  } else if (shot.harness !== null) {