jixoai-ui 0.2.0 → 0.4.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
@@ -16,11 +16,42 @@ The official jixoai design-language CLI. It **shares shadcn's
16
16
  ```bash
17
17
  npx jixoai-ui init --hue 160 # namespace + config + theme + hue, one shot
18
18
  npx jixoai-ui add toc # = shadcn add @jixoai/toc, hue re-applied
19
+ npx jixoai-ui add effects # group alias: every ui item in the group
20
+ npx jixoai-ui add effects/glass # one member (membership validated)
21
+ npx jixoai-ui add llms-txt # AI export: llms.txt / llms-full.txt / page .md
19
22
  npx jixoai-ui upgrade # refresh locked items + run upgrade tasks
20
23
  npx jixoai-ui hue 165 # retheme by changing one number
21
24
  npx jixoai-ui config # print the resolved jixoai config
22
25
  ```
23
26
 
27
+ ## Group aliases
28
+
29
+ `add` accepts three argument forms (effect-attachments Lane H):
30
+
31
+ - **Item name** — `npx jixoai-ui add glass`: as-is, any registry type. An
32
+ exact item name always wins when an item and a group id collide.
33
+ - **Group id** — `npx jixoai-ui add effects`: expands to EVERY
34
+ `registry:ui` item whose `meta.group` is `effects`, in registry order,
35
+ and prints the expansion (`jixoai-ui: effects → press-button, glass`).
36
+ The expansion is what installs AND what enters `jixoai-ui.lock` — the
37
+ lock records item names, never group ids.
38
+ - **Scoped member** — `npx jixoai-ui add effects/glass`: resolves to
39
+ `glass` after validating the group actually owns the item; a wrong
40
+ group (`add effects/toc`) exits non-zero naming the item's real group
41
+ and the requested one.
42
+
43
+ Groups are an ADD-time convenience: `adopt` and `upgrade` stay
44
+ item-name-only. Membership comes from `/r/registry.json` (the registry
45
+ index) — a custom registry URL without an index keeps the standing
46
+ bare-name behavior for plain adds and refuses the scoped form.
47
+
48
+ `llms-txt` installs `vite-plugins/llms-txt.mjs` — the build-time
49
+ llms.txt/llms-full.txt/per-page-`.md` generator (llmstxt.org proposal
50
+ v2). Wire it ONE way: `llmsTxt()` in vite plugins for plain-build sites,
51
+ or `generateLlmsTxt(distDir, config)` as the last step of an orchestrated
52
+ build. Full law + config schema:
53
+ `skills/jixoai-website/references/llms-txt.md`.
54
+
24
55
  Requires `components.json` (run `npx shadcn init` first in fresh projects —
25
56
  this CLI extends shadcn's config, it never replaces it).
26
57
 
@@ -34,7 +65,9 @@ and in any shell loop.
34
65
  - **Lock**: `init`/`add` record every installed item in `jixoai-ui.lock`
35
66
  (next to `components.json`) as
36
67
  `{ items: { [name]: { files: { [path]: sha256 } } } }`. Paths are resolved
37
- through `components.json` aliases; hashes cover canonical registry content
68
+ through `components.json` aliases (`$lib`-rooted values resolve through
69
+ the project's tsconfig/jsconfig `compilerOptions.paths`, the same map
70
+ shadcn uses); hashes cover canonical registry content
38
71
  (pre-hue, pre-task). A missing or empty lock fails with exit code 1 and
39
72
  tells you to `add` first.
40
73
  - **Refresh**: every locked item is fetched from `registries["@jixoai"]`
package/bin/jixoai-ui.mjs CHANGED
@@ -29,11 +29,29 @@
29
29
  * registry sha256 differs from the locked one, re-applies hue, then runs
30
30
  * the idempotent upgrade tasks (bin/upgrade-tasks.mjs) — a converged
31
31
  * second run performs zero writes.
32
+ *
33
+ * Install integrity (consumer-feedback-fixes P0-3, 2026-09-06): "successful"
34
+ * means VERIFIED ON DISK — an item enters the lock only when every one of
35
+ * its files exists at its alias-resolved path (a non-interactive shadcn run
36
+ * whose overwrite prompt hits EOF cancels its write phase; the item then
37
+ * stays unlocked with an explicit recovery warning). After every add phase
38
+ * the CLI also relocates files shadcn dropped into literal `src/@lib/`,
39
+ * `src/@ui/` and `src/vite-plugins/` directories to their alias-resolved
40
+ * destinations, and item-name parsing skips `--` tokens (flags never
41
+ * masquerade as `@jixoai/--help`).
32
42
  */
33
43
 
34
44
  import { spawnSync } from "node:child_process";
35
45
  import { createHash } from "node:crypto";
36
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
46
+ import {
47
+ existsSync,
48
+ mkdirSync,
49
+ readdirSync,
50
+ readFileSync,
51
+ renameSync,
52
+ rmdirSync,
53
+ writeFileSync,
54
+ } from "node:fs";
37
55
  import { dirname, join, relative, resolve } from "node:path";
38
56
  import process from "node:process";
39
57
 
@@ -56,7 +74,11 @@ Commands:
56
74
  lock (first upgrade then syncs to canon)
57
75
  jixoai-ui add <item...> install registry items (delegates to
58
76
  \`shadcn add ${NAMESPACE}/<item>\`), then
59
- re-applies the brand hue
77
+ re-applies the brand hue. Group
78
+ aliases: \`add effects\` installs
79
+ every ui item in the group,
80
+ \`add effects/glass\` installs one
81
+ member
60
82
  jixoai-ui upgrade refresh every locked item to the latest
61
83
  registry content, re-apply the brand
62
84
  hue, and run the idempotent upgrade
@@ -95,10 +117,65 @@ function ensureNamespace(config) {
95
117
  }
96
118
  }
97
119
 
120
+ /* ── $-alias resolution (effect-attachments Lane H, 2026-09-10) ──
121
+ *
122
+ * shadcn-svelte consumers carry `$lib`-ROOTED alias values
123
+ * (`"ui": "$lib/ui"` — the frozen table the clean-install harness
124
+ * proves). A literal `$lib` directory never exists on disk, so every
125
+ * alias base must first resolve through the project's
126
+ * tsconfig/jsconfig `compilerOptions.paths` (the same map shadcn
127
+ * itself resolves aliases with) before it becomes a filesystem path.
128
+ * Before this, `add` on such consumers installed fine but the lock
129
+ * found ZERO files at "(no) install path" and recorded nothing —
130
+ * `upgrade` went dead while the files sat in place. A base that maps
131
+ * nowhere keeps its literal meaning; `extends`-chained configs are a
132
+ * known limit (the direct paths table wins). */
133
+ const aliasResolverCache = new Map(); // cwd → (base → resolved base)
134
+
135
+ function tsconfigPathsFor(cwd) {
136
+ for (const name of ["tsconfig.json", "jsconfig.json"]) {
137
+ const path = join(cwd, name);
138
+ if (!existsSync(path)) continue;
139
+ try {
140
+ const paths = JSON.parse(readFileSync(path, "utf8"))?.compilerOptions?.paths;
141
+ if (paths && typeof paths === "object") return paths;
142
+ } catch {
143
+ // an unparseable config is not fatal — the literal base stands
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function aliasBaseResolver(cwd) {
150
+ if (aliasResolverCache.has(cwd)) return aliasResolverCache.get(cwd);
151
+ const paths = tsconfigPathsFor(cwd);
152
+ const star = (v) => String(Array.isArray(v) ? v[0] ?? "" : v ?? "").replace(/\*$/, "");
153
+ const resolveBase = (base) => {
154
+ if (!paths || !base.startsWith("$")) return base;
155
+ if (paths[base] !== undefined) return star(paths[base]);
156
+ const wildcards = Object.keys(paths)
157
+ .filter((k) => k.endsWith("/*"))
158
+ .map((k) => k.slice(0, -1)) // '$lib/*' → '$lib/'
159
+ .sort((a, b) => b.length - a.length); // longest prefix wins
160
+ for (const prefix of wildcards) {
161
+ if (base.startsWith(prefix)) return star(paths[`${prefix}*`]) + base.slice(prefix.length);
162
+ }
163
+ return base;
164
+ };
165
+ aliasResolverCache.set(cwd, resolveBase);
166
+ return resolveBase;
167
+ }
168
+
169
+ /** an alias VALUE (`"$lib/ui"` / `"src/lib/ui"`) → a cwd-relative path */
170
+ function aliasDir(aliasValue, cwd) {
171
+ return resolve(cwd, aliasBaseResolver(cwd)(aliasValue));
172
+ }
173
+
98
174
  function themeCssPath(config, cwd) {
99
175
  const lib = config.aliases?.lib;
100
176
  if (typeof lib !== "string") return null;
101
- const candidates = [resolve(cwd, lib, "jixoai.css"), resolve(cwd, `${lib}.css`)];
177
+ const base = aliasBaseResolver(cwd)(lib);
178
+ const candidates = [resolve(cwd, base, "jixoai.css"), resolve(cwd, `${base}.css`)];
102
179
  for (const candidate of candidates) {
103
180
  if (existsSync(candidate)) return candidate;
104
181
  }
@@ -201,6 +278,147 @@ async function fetchRegistryItem(registryUrl, name) {
201
278
  return json;
202
279
  }
203
280
 
281
+ /**
282
+ * Fetch + parse the registry INDEX — the same `/r/registry.json` the
283
+ * public site serves (`shadcn build` emits it from registry.json with
284
+ * `meta.group` intact, so group membership survives the pipeline).
285
+ * Mirrors fetchRegistryItem's URL building: the `{name}` template
286
+ * becomes the literal `registry`, so `https://ui.jixoai.com/r/{name}.json`
287
+ * resolves `https://ui.jixoai.com/r/registry.json` and local/file://
288
+ * mirrors land on their own index the same way.
289
+ */
290
+ async function fetchRegistryIndex(registryUrl) {
291
+ const url = registryUrl.replace("{name}", "registry");
292
+ let raw;
293
+ try {
294
+ raw = await fetchText(url);
295
+ } catch (cause) {
296
+ throw new Error(`cannot fetch the registry index from ${url}: ${cause.message}`);
297
+ }
298
+ let json;
299
+ try {
300
+ json = JSON.parse(raw);
301
+ } catch {
302
+ throw new Error(`the registry index (${url}) is not valid JSON`);
303
+ }
304
+ const items = Array.isArray(json) ? json : json.items;
305
+ if (!Array.isArray(items) || items.some((i) => typeof i?.name !== "string")) {
306
+ throw new Error(`the registry index (${url}) has no usable items array`);
307
+ }
308
+ return items;
309
+ }
310
+
311
+ /**
312
+ * Resolve `add` arguments to registry item names (effect-attachments
313
+ * Lane H, the r5 Owner request #2, 2026-09-10 — `npx jixoai-ui add
314
+ * effects` / `npx jixoai-ui add effects/glass`):
315
+ *
316
+ * effects GROUP alias — expands to EVERY registry:ui item
317
+ * whose `meta.group === 'effects'`, in REGISTRY
318
+ * ORDER (the index's item order). Groups with zero
319
+ * registry:ui members (e.g. `engines` — lib-only)
320
+ * are not add-able ids.
321
+ * effects/glass SCOPED member — resolves to `glass` after proving
322
+ * the item EXISTS and its `meta.group` is exactly
323
+ * `effects` (a violation names both groups).
324
+ * glass ITEM name — as-is, any registry type. PRECEDENCE
325
+ * LAW: an exact item name ALWAYS wins over a group
326
+ * id when the two collide (none collide today; the
327
+ * law is fixed here so a future `effects` ITEM
328
+ * simply shadows the group instead of changing the
329
+ * resolution rules).
330
+ *
331
+ * `adopt`/`upgrade` stay ITEM-NAME-ONLY on purpose: groups are an
332
+ * ADD-time convenience, and the lock + the upgrade loop record items,
333
+ * never group ids.
334
+ *
335
+ * Degradation: a registry without an index (single-item file://
336
+ * fixtures) keeps the standing bare-name behavior — the index is
337
+ * fetched ONCE per call, and a failed fetch downgrades to a warning
338
+ * when every arg is a plain name, while the scoped form hard-fails
339
+ * (it cannot be validated without the index).
340
+ */
341
+ export async function resolveAddNames(registryUrl, args) {
342
+ let index = null;
343
+ let indexError = null;
344
+ try {
345
+ index = await fetchRegistryIndex(registryUrl);
346
+ } catch (cause) {
347
+ indexError = cause.message;
348
+ }
349
+ if (indexError && args.some((a) => a.includes("/"))) {
350
+ fail(indexError);
351
+ }
352
+ if (indexError) {
353
+ console.warn(
354
+ `jixoai-ui: ${indexError} — treating every argument as an item name (group aliases need the registry index)`,
355
+ );
356
+ return [...new Set(args)];
357
+ }
358
+ const byName = new Map(index.map((i) => [i.name, i]));
359
+ const groups = new Map(); // group id → registry:ui member names, index order
360
+ for (const item of index) {
361
+ if (item.type !== "registry:ui") continue;
362
+ const group = item.meta?.group;
363
+ if (typeof group !== "string") continue;
364
+ if (!groups.has(group)) groups.set(group, []);
365
+ groups.get(group).push(item.name);
366
+ }
367
+ const knownGroups = () => [...groups.keys()].join(", ");
368
+
369
+ const resolved = [];
370
+ const seen = new Set(); // `add effects glass` installs glass once
371
+ const push = (name) => {
372
+ if (!seen.has(name)) {
373
+ seen.add(name);
374
+ resolved.push(name);
375
+ }
376
+ };
377
+
378
+ for (const arg of args) {
379
+ if (!arg.includes("/")) {
380
+ // bare form: an exact ITEM name always wins (see the precedence law)
381
+ if (byName.has(arg)) {
382
+ push(arg);
383
+ continue;
384
+ }
385
+ const members = groups.get(arg);
386
+ if (members) {
387
+ console.log(`jixoai-ui: ${arg} → ${members.join(", ")}`);
388
+ for (const name of members) push(name);
389
+ continue;
390
+ }
391
+ fail(
392
+ `unknown item or group \`${arg}\` — known groups: ${knownGroups()}. ` +
393
+ `Pick an item from the registry index or a group id above`,
394
+ );
395
+ }
396
+ // scoped form: exactly one slash, both sides non-empty
397
+ const parts = arg.split("/");
398
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
399
+ fail(`\`${arg}\` is not a valid scoped item — expected \`group/name\` (e.g. \`effects/glass\`)`);
400
+ }
401
+ const [group, name] = parts;
402
+ if (!byName.has(name)) {
403
+ const members = groups.get(group);
404
+ fail(
405
+ members
406
+ ? `\`${arg}\`: no registry item named \`${name}\` — group \`${group}\` has: ${members.join(", ")}`
407
+ : `\`${arg}\`: no registry item named \`${name}\` and \`${group}\` is not a known group — known groups: ${knownGroups()}`,
408
+ );
409
+ }
410
+ const realGroup = byName.get(name).meta?.group;
411
+ if (realGroup !== group) {
412
+ fail(
413
+ `\`${arg}\`: \`${name}\` is not in group \`${group}\` — it belongs to ` +
414
+ `\`${realGroup ?? "(no group)"}\`. Use \`npx jixoai-ui add ${name}\` instead`,
415
+ );
416
+ }
417
+ push(name);
418
+ }
419
+ return resolved;
420
+ }
421
+
204
422
  function assertRegistryFiles(name, files) {
205
423
  for (const file of files) {
206
424
  if (typeof file.target !== "string" || typeof file.content !== "string") {
@@ -212,13 +430,15 @@ function assertRegistryFiles(name, files) {
212
430
  function resolveInstallPath(target, config, cwd) {
213
431
  // registry targets are alias-relative ("@ui/toc.svelte" → aliases.ui) or
214
432
  // plain project-relative paths; mirrors how shadcn places registry files.
433
+ // `$`-rooted alias values resolve through the project's tsconfig paths
434
+ // (aliasDir) — see the $-alias resolution block above.
215
435
  const match = /^@([\w.$-]+)(?:\/(.+))?$/.exec(target);
216
436
  if (match) {
217
437
  const base = config.aliases?.[match[1]];
218
438
  if (typeof base !== "string") {
219
439
  throw new Error(`cannot place \`${target}\`: components.json has no aliases.${match[1]}`);
220
440
  }
221
- return resolve(cwd, join(base, match[2] ?? ""));
441
+ return resolve(aliasDir(base, cwd), match[2] ?? "");
222
442
  }
223
443
  return resolve(cwd, target);
224
444
  }
@@ -262,6 +482,15 @@ function writeLock(path, lock) {
262
482
  * Record freshly installed items in the lock. Shared by add/init; upgrade
263
483
  * reuses the same fetch/hash/place helpers. The install itself already
264
484
  * succeeded, so a recording failure warns instead of failing the command.
485
+ *
486
+ * Install-integrity gate (consumer-feedback-fixes P0-3, 2026-09-06):
487
+ * an item is locked ONLY when every one of its files exists on disk at
488
+ * the alias-resolved install path. A non-interactive shadcn run whose
489
+ * overwrite confirmation hits EOF cancels its whole write phase — the
490
+ * files never land, and the pre-gate CLI locked the item anyway, so
491
+ * `upgrade` reported it as managed while nothing was installed. A miss
492
+ * now keeps the item OUT of the lock and prints the missing paths with
493
+ * the recovery guidance.
265
494
  */
266
495
  async function recordInstalledItems(cwd, config, names) {
267
496
  const registryUrl = registryUrlFor(config);
@@ -272,8 +501,27 @@ async function recordInstalledItems(cwd, config, names) {
272
501
  const item = await fetchRegistryItem(registryUrl, name);
273
502
  assertRegistryFiles(name, item.files);
274
503
  const files = {};
504
+ const missing = [];
275
505
  for (const file of item.files) {
276
- files[lockInstallKey(file.target, config, cwd)] = sha256(file.content);
506
+ const key = lockInstallKey(file.target, config, cwd);
507
+ if (!existsSync(resolve(cwd, key))) {
508
+ missing.push(key);
509
+ continue;
510
+ }
511
+ files[key] = sha256(file.content);
512
+ }
513
+ if (missing.length > 0) {
514
+ console.warn(
515
+ `jixoai-ui: ${name} NOT locked in ${LOCK_NAME} — ${missing.length} of ` +
516
+ `${item.files.length} file(s) missing at their install path(s):`,
517
+ );
518
+ for (const key of missing) console.warn(` - ${key}`);
519
+ console.warn(
520
+ "jixoai-ui: the write phase was cancelled (shadcn's overwrite confirmation " +
521
+ "hit EOF under non-interactive stdin). Move the conflicting existing file(s) " +
522
+ "aside and re-run the add; this CLI re-applies the brand hue afterwards.",
523
+ );
524
+ continue;
277
525
  }
278
526
  lock.items[name] = { ...(lock.items[name] ?? {}), files };
279
527
  recorded++;
@@ -289,6 +537,64 @@ async function recordInstalledItems(cwd, config, names) {
289
537
  }
290
538
  }
291
539
 
540
+ /* ── post-add relocation (consumer-feedback-fixes P0-3) ── */
541
+
542
+ /**
543
+ * shadcn sometimes ignores the registry item's alias targets and drops
544
+ * files into LITERAL `src/@lib/`, `src/@ui/` and `src/vite-plugins/`
545
+ * directories (the alias prefix treated as a path segment). This pass
546
+ * walks those literal directories, moves every dropped file to its
547
+ * alias-resolved (or project-root) destination, reports each move, and
548
+ * removes the emptied literal directories. Destination collisions are
549
+ * reported, never clobbered.
550
+ */
551
+ function relocateMisplacedFiles(cwd, config) {
552
+ const alias = (name) => {
553
+ const base = config.aliases?.[name];
554
+ if (typeof base !== "string") return null;
555
+ return aliasDir(base, cwd); // $-rooted values resolve through tsconfig paths
556
+ };
557
+ const sources = [
558
+ { dir: resolve(cwd, "src/@lib"), destination: alias("lib") },
559
+ { dir: resolve(cwd, "src/@ui"), destination: alias("ui") },
560
+ { dir: resolve(cwd, "src/@components"), destination: alias("components") },
561
+ // plain project-relative targets (e.g. vite-plugins/llms-txt.mjs)
562
+ // that shadcn still anchors under src/
563
+ { dir: resolve(cwd, "src/vite-plugins"), destination: resolve(cwd, "vite-plugins") },
564
+ ];
565
+ for (const { dir, destination } of sources) {
566
+ if (!destination || !existsSync(dir)) continue;
567
+ const entries = readdirSync(dir, { withFileTypes: true });
568
+ if (entries.length === 0) {
569
+ rmdirSync(dir);
570
+ continue;
571
+ }
572
+ const moveTree = (fromDir, toDir) => {
573
+ mkdirSync(toDir, { recursive: true });
574
+ for (const entry of readdirSync(fromDir, { withFileTypes: true })) {
575
+ const from = join(fromDir, entry.name);
576
+ const to = join(toDir, entry.name);
577
+ if (entry.isDirectory()) {
578
+ moveTree(from, to);
579
+ rmdirSync(from);
580
+ } else if (existsSync(to)) {
581
+ console.warn(
582
+ `jixoai-ui: relocation skipped — ${toPosix(relative(cwd, to))} already exists; ` +
583
+ `${toPosix(relative(cwd, from))} left in place`,
584
+ );
585
+ } else {
586
+ renameSync(from, to);
587
+ console.log(
588
+ `jixoai-ui: relocated ${toPosix(relative(cwd, from))} → ${toPosix(relative(cwd, to))}`,
589
+ );
590
+ }
591
+ }
592
+ };
593
+ moveTree(dir, destination);
594
+ if (existsSync(dir) && readdirSync(dir).length === 0) rmdirSync(dir);
595
+ }
596
+ }
597
+
292
598
  /* ── upgrade ── */
293
599
 
294
600
  function appCssCandidates(cwd, config, lock) {
@@ -407,18 +713,19 @@ const cwd = process.cwd();
407
713
  switch (command) {
408
714
  case "init": {
409
715
  const { path, config } = readConfig(cwd);
410
- const hue = hueFromArgs(rest, config.jixoai?.brandHue ?? DEFAULT_HUE);
716
+ const hue = hueFromArgs(rest.filter((a) => !a.startsWith("--")), config.jixoai?.brandHue ?? DEFAULT_HUE);
411
717
  ensureNamespace(config);
412
718
  config.jixoai = { ...(config.jixoai ?? {}), brandHue: hue };
413
719
  writeConfig(path, config);
414
720
  console.log(`jixoai-ui: ${NAMESPACE} namespace + jixoai config written → ${path}`);
415
721
  shadcn(["add", `${NAMESPACE}/${THEME_ITEM}`], cwd, path, config);
722
+ relocateMisplacedFiles(cwd, readConfig(cwd).config);
416
723
  applyHue(themeCssPath(config, cwd), hue);
417
724
  await recordInstalledItems(cwd, readConfig(cwd).config, [THEME_ITEM]);
418
725
  break;
419
726
  }
420
727
  case "hue": {
421
- const hue = hueFromArgs(["--hue", rest[0]]);
728
+ const hue = hueFromArgs(["--hue", rest.find((a) => !a.startsWith("--"))]);
422
729
  const { path, config } = readConfig(cwd);
423
730
  config.jixoai = { ...(config.jixoai ?? {}), brandHue: hue };
424
731
  writeConfig(path, config);
@@ -426,14 +733,35 @@ switch (command) {
426
733
  break;
427
734
  }
428
735
  case "add": {
429
- if (rest.length === 0) fail("add needs at least one item name (e.g. `toc`)");
736
+ // arg discipline (consumer-feedback-fixes P0-3): `--` tokens are
737
+ // flags, never item names (`add --help` used to spawn
738
+ // `shadcn add @jixoai/--help`)
739
+ if (rest.some((a) => a === "--help" || a === "-h")) {
740
+ console.log(USAGE);
741
+ break;
742
+ }
743
+ const items = rest.filter((a) => !a.startsWith("--"));
744
+ if (items.length === 0) {
745
+ fail("add needs at least one item name (e.g. `toc`, a group id like `effects`, or `effects/glass`)");
746
+ }
430
747
  const { path, config } = readConfig(cwd);
748
+ // group aliases (effect-attachments Lane H): `add effects` /
749
+ // `add effects/glass` resolve to ITEM names BEFORE the shadcn
750
+ // loop — the loop, the lock and the recording all speak RESOLVED
751
+ // names (so `add effects` locks glass + press-button, never an
752
+ // `effects` key)
753
+ const resolved = await resolveAddNames(registryUrlFor(config), items);
431
754
  const hue = config.jixoai?.brandHue ?? DEFAULT_HUE;
432
- for (const item of rest) {
755
+ // one shadcn invocation PER ITEM, each carrying the @jixoai/ prefix
756
+ // itself (consumer-feedback-fixes P0-3 audit: the prefix must never
757
+ // depend on shell/shadcn multi-arg behavior — the loop re-reads the
758
+ // config because shadcn may rewrite it between spawns)
759
+ for (const item of resolved) {
433
760
  shadcn(["add", `${NAMESPACE}/${item}`], cwd, path, readConfig(cwd).config);
434
761
  }
762
+ relocateMisplacedFiles(cwd, readConfig(cwd).config);
435
763
  applyHue(themeCssPath(config, cwd), hue);
436
- await recordInstalledItems(cwd, readConfig(cwd).config, rest);
764
+ await recordInstalledItems(cwd, readConfig(cwd).config, resolved);
437
765
  break;
438
766
  }
439
767
  case "adopt": {
@@ -441,7 +769,9 @@ switch (command) {
441
769
  // baselines the CURRENT disk content of the named items into the lock:
442
770
  // the first `upgrade` afterwards diffs registry canon against this
443
771
  // baseline, applies changes + hue, and the lock flips to canonical
444
- // hashes — subsequent upgrades are fully idempotent.
772
+ // hashes — subsequent upgrades are fully idempotent. Item names only:
773
+ // group aliases (`effects`, `effects/glass`) are an ADD-time
774
+ // convenience and never expand here (see resolveAddNames).
445
775
  const names = rest.filter((a) => !a.startsWith("--"));
446
776
  if (names.length === 0) {
447
777
  fail("adopt needs item names (e.g. `adopt toc jixoai-theme`) — items whose files live at their components.json targets");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jixoai-ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Official jixoai design-language CLI: initializes the @jixoai shadcn registry namespace, manages the jixoai extension fields in components.json, applies the per-project brand hue, and performs locked idempotent upgrades.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,4 +29,4 @@
29
29
  "url": "https://github.com/jixoai/ui",
30
30
  "directory": "cli"
31
31
  }
32
- }
32
+ }