jixoai-ui 0.1.2 → 0.2.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,9 +16,50 @@ 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 upgrade # refresh locked items + run upgrade tasks
19
20
  npx jixoai-ui hue 165 # retheme by changing one number
20
21
  npx jixoai-ui config # print the resolved jixoai config
21
22
  ```
22
23
 
23
24
  Requires `components.json` (run `npx shadcn init` first in fresh projects —
24
25
  this CLI extends shadcn's config, it never replaces it).
26
+
27
+ ## upgrade
28
+
29
+ `npx jixoai-ui upgrade` pulls the latest version of every installed
30
+ component and runs the idempotent upgrade tasks. Running it again changes
31
+ nothing — a converged second run performs zero writes, so it is safe in CI
32
+ and in any shell loop.
33
+
34
+ - **Lock**: `init`/`add` record every installed item in `jixoai-ui.lock`
35
+ (next to `components.json`) as
36
+ `{ items: { [name]: { files: { [path]: sha256 } } } }`. Paths are resolved
37
+ through `components.json` aliases; hashes cover canonical registry content
38
+ (pre-hue, pre-task). A missing or empty lock fails with exit code 1 and
39
+ tells you to `add` first.
40
+ - **Refresh**: every locked item is fetched from `registries["@jixoai"]`
41
+ with `{name}` replaced (`file://` URLs work for local registries). A file
42
+ is written only when its registry sha256 differs from the locked one;
43
+ identical content is skipped and counted as unchanged. Network, HTTP, and
44
+ JSON failures abort with an explicit error and exit code 1.
45
+ - **Hue**: after the writes the brand hue is re-applied to `jixoai.css`.
46
+ - **Tasks**: `bin/upgrade-tasks.mjs` exports the task array
47
+ `[{ name, item?, applies(content, ctx), run(ctx) }]`. A task fires only
48
+ while its legacy pattern still exists (`applies`), so re-running always
49
+ converges:
50
+ - `legacy-import-paths` — `@lib/toc-engine` → `$lib/toc-engine` and
51
+ `../lib/toc.css` → `$lib/toc.css` (only where the old specifier exists).
52
+ - `spine-axis` (item `toc`) — `left: 2px` → `left: 0px` in the toc css.
53
+ - `scroll-margin-cleanup` — diagnostic only: warns when the app-level css
54
+ declares both `scroll-margin-top` and `scroll-padding-top` (the offsets
55
+ stack; which one owns the offset is an app decision jixoai-ui never
56
+ makes for you). It never edits files, so it re-reports on every run
57
+ until the redundancy is removed.
58
+ - **Summary**: `updated N / unchanged M / tasks ran X, skipped Y`, then the
59
+ lock hashes are updated.
60
+
61
+ CI usage — always upgrade, build, and test against the freshest components:
62
+
63
+ ```bash
64
+ npx jixoai-ui upgrade && npm run build && npm test
65
+ ```
package/bin/jixoai-ui.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * jixoai-ui — the official jixoai design-language CLI (bin/jixoai-ui.mjs).
4
4
  *
5
5
  * Orthogonal intents (2026-08-20): components.json extension management;
6
- * brand-hue application; registry add delegation.
6
+ * brand-hue application; registry add delegation; locked idempotent upgrades.
7
7
  *
8
8
  * Config contract (Owner decision, 2026-08-20): the CLI SHARES shadcn's
9
9
  * components.json and extends it with a non-conflicting `jixoai` object:
@@ -16,18 +16,34 @@
16
16
  *
17
17
  * The registry URL points at the official Pages-hosted registry; hue lives
18
18
  * in ONE config field and is written into the installed jixoai.css
19
- * (--brand-hue) on every init/add/hue run.
19
+ * (--brand-hue) on every init/add/hue/upgrade run.
20
+ *
21
+ * Install manifest (Owner decision, 2026-08-20): successful init/add runs
22
+ * record every installed item in `jixoai-ui.lock` (next to components.json):
23
+ *
24
+ * { "items": { "toc": { "files": { "src/lib/ui/toc.svelte": "<sha256>" } } } }
25
+ *
26
+ * Keys are install paths resolved through components.json aliases; hashes
27
+ * cover CANONICAL registry content (pre-hue, pre-task). `upgrade` fetches
28
+ * each locked item from registries["@jixoai"], writes only files whose
29
+ * registry sha256 differs from the locked one, re-applies hue, then runs
30
+ * the idempotent upgrade tasks (bin/upgrade-tasks.mjs) — a converged
31
+ * second run performs zero writes.
20
32
  */
21
33
 
22
34
  import { spawnSync } from "node:child_process";
23
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
24
- import { join, resolve } from "node:path";
35
+ import { createHash } from "node:crypto";
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
37
+ import { dirname, join, relative, resolve } from "node:path";
25
38
  import process from "node:process";
26
39
 
40
+ import { upgradeTasks } from "./upgrade-tasks.mjs";
41
+
27
42
  const REGISTRY_URL = "https://ui.jixoai.com/r/{name}.json";
28
43
  const NAMESPACE = "@jixoai";
29
44
  const THEME_ITEM = "jixoai-theme";
30
45
  const DEFAULT_HUE = 0;
46
+ const LOCK_NAME = "jixoai-ui.lock";
31
47
 
32
48
  const USAGE = `jixoai-ui — the jixoai design language CLI
33
49
 
@@ -36,9 +52,15 @@ Commands:
36
52
  jixoai config block, install the theme,
37
53
  and apply the brand hue
38
54
  jixoai-ui hue <degrees> set the project brand hue (config + css)
55
+ jixoai-ui adopt <item...> baseline hand-installed items into the
56
+ lock (first upgrade then syncs to canon)
39
57
  jixoai-ui add <item...> install registry items (delegates to
40
58
  \`shadcn add ${NAMESPACE}/<item>\`), then
41
59
  re-applies the brand hue
60
+ jixoai-ui upgrade refresh every locked item to the latest
61
+ registry content, re-apply the brand
62
+ hue, and run the idempotent upgrade
63
+ tasks (repeat runs perform zero writes)
42
64
  jixoai-ui config print the resolved jixoai config
43
65
 
44
66
  The CLI extends shadcn's components.json — run \`npx shadcn init\` first in
@@ -128,6 +150,257 @@ function hueFromArgs(args, fallback = DEFAULT_HUE) {
128
150
  return Math.round(value);
129
151
  }
130
152
 
153
+ /* ── install manifest (jixoai-ui.lock) + shared install/upgrade core ── */
154
+
155
+ function sha256(text) {
156
+ return createHash("sha256").update(text, "utf8").digest("hex");
157
+ }
158
+
159
+ function toPosix(path) {
160
+ return path.split("\\").join("/");
161
+ }
162
+
163
+ function registryUrlFor(config) {
164
+ const url = config.registries?.[NAMESPACE] ?? REGISTRY_URL;
165
+ if (!url.includes("{name}")) {
166
+ fail(`the ${NAMESPACE} registry url must contain a {name} template (got \`${url}\`)`);
167
+ }
168
+ return url;
169
+ }
170
+
171
+ async function fetchText(url) {
172
+ // file:// registries are read from disk (local/offline development);
173
+ // everything else goes through global fetch.
174
+ if (url.startsWith("file:")) {
175
+ return readFileSync(new URL(url), "utf8");
176
+ }
177
+ const response = await fetch(url);
178
+ if (!response.ok) {
179
+ throw new Error(`HTTP ${response.status}`);
180
+ }
181
+ return response.text();
182
+ }
183
+
184
+ async function fetchRegistryItem(registryUrl, name) {
185
+ const url = registryUrl.replace("{name}", name);
186
+ let raw;
187
+ try {
188
+ raw = await fetchText(url);
189
+ } catch (cause) {
190
+ throw new Error(`cannot fetch registry item \`${name}\` from ${url}: ${cause.message}`);
191
+ }
192
+ let json;
193
+ try {
194
+ json = JSON.parse(raw);
195
+ } catch {
196
+ throw new Error(`registry item \`${name}\` (${url}) is not valid JSON`);
197
+ }
198
+ if (!Array.isArray(json.files)) {
199
+ throw new Error(`registry item \`${name}\` (${url}) has no files array`);
200
+ }
201
+ return json;
202
+ }
203
+
204
+ function assertRegistryFiles(name, files) {
205
+ for (const file of files) {
206
+ if (typeof file.target !== "string" || typeof file.content !== "string") {
207
+ throw new Error(`registry item \`${name}\` has a file without string target/content`);
208
+ }
209
+ }
210
+ }
211
+
212
+ function resolveInstallPath(target, config, cwd) {
213
+ // registry targets are alias-relative ("@ui/toc.svelte" → aliases.ui) or
214
+ // plain project-relative paths; mirrors how shadcn places registry files.
215
+ const match = /^@([\w.$-]+)(?:\/(.+))?$/.exec(target);
216
+ if (match) {
217
+ const base = config.aliases?.[match[1]];
218
+ if (typeof base !== "string") {
219
+ throw new Error(`cannot place \`${target}\`: components.json has no aliases.${match[1]}`);
220
+ }
221
+ return resolve(cwd, join(base, match[2] ?? ""));
222
+ }
223
+ return resolve(cwd, target);
224
+ }
225
+
226
+ function lockInstallKey(target, config, cwd) {
227
+ return toPosix(relative(cwd, resolveInstallPath(target, config, cwd)));
228
+ }
229
+
230
+ function readLock(cwd, { required }) {
231
+ const path = join(cwd, LOCK_NAME);
232
+ if (!existsSync(path)) {
233
+ if (required) {
234
+ fail(
235
+ `${LOCK_NAME} not found — nothing to upgrade. Install components first: \`npx jixoai-ui add <item>\``,
236
+ );
237
+ }
238
+ return { path, lock: { items: {} }, existed: false };
239
+ }
240
+ let lock;
241
+ try {
242
+ lock = JSON.parse(readFileSync(path, "utf8"));
243
+ } catch (cause) {
244
+ fail(`${LOCK_NAME} is not valid JSON: ${cause.message}`);
245
+ }
246
+ if (
247
+ typeof lock !== "object" || lock === null ||
248
+ typeof lock.items !== "object" || lock.items === null
249
+ ) {
250
+ fail(
251
+ `${LOCK_NAME} must contain an \`items\` map ({ items: { [name]: { files: { [path]: sha256 } } } })`,
252
+ );
253
+ }
254
+ return { path, lock, existed: true };
255
+ }
256
+
257
+ function writeLock(path, lock) {
258
+ writeFileSync(path, JSON.stringify(lock, null, 2) + "\n");
259
+ }
260
+
261
+ /**
262
+ * Record freshly installed items in the lock. Shared by add/init; upgrade
263
+ * reuses the same fetch/hash/place helpers. The install itself already
264
+ * succeeded, so a recording failure warns instead of failing the command.
265
+ */
266
+ async function recordInstalledItems(cwd, config, names) {
267
+ const registryUrl = registryUrlFor(config);
268
+ const { path, lock, existed } = readLock(cwd, { required: false });
269
+ let recorded = 0;
270
+ for (const name of names) {
271
+ try {
272
+ const item = await fetchRegistryItem(registryUrl, name);
273
+ assertRegistryFiles(name, item.files);
274
+ const files = {};
275
+ for (const file of item.files) {
276
+ files[lockInstallKey(file.target, config, cwd)] = sha256(file.content);
277
+ }
278
+ lock.items[name] = { ...(lock.items[name] ?? {}), files };
279
+ recorded++;
280
+ console.log(`jixoai-ui: locked ${name} (${Object.keys(files).length} file(s)) → ${path}`);
281
+ } catch (cause) {
282
+ console.warn(
283
+ `jixoai-ui: ${cause.message} — ${name} not recorded in ${LOCK_NAME}; \`upgrade\` will skip it`,
284
+ );
285
+ }
286
+ }
287
+ if (recorded > 0 || existed) {
288
+ writeLock(path, lock);
289
+ }
290
+ }
291
+
292
+ /* ── upgrade ── */
293
+
294
+ function appCssCandidates(cwd, config, lock) {
295
+ // site-scope files upgrade tasks may inspect: the app-level css pointed
296
+ // at by components.json (tailwind.css) plus conventional locations,
297
+ // minus anything already covered by the lock.
298
+ const locked = new Set();
299
+ for (const item of Object.values(lock.items)) {
300
+ for (const key of Object.keys(item.files ?? {})) locked.add(key);
301
+ }
302
+ const candidates = new Set();
303
+ const tailwindCss = config.tailwind?.css;
304
+ if (typeof tailwindCss === "string" && tailwindCss) candidates.add(toPosix(tailwindCss));
305
+ for (const fallback of ["src/app.css", "app.css", "src/app.postcss", "src/styles/globals.css"]) {
306
+ candidates.add(fallback);
307
+ }
308
+ return [...candidates].filter((key) => !locked.has(key) && existsSync(resolve(cwd, key)));
309
+ }
310
+
311
+ function taskContext(cwd, item, key) {
312
+ const filePath = resolve(cwd, key);
313
+ return {
314
+ cwd,
315
+ item, // registry item name, or null for site-scope entries
316
+ path: key,
317
+ filePath,
318
+ get content() {
319
+ return readFileSync(filePath, "utf8");
320
+ },
321
+ read: () => readFileSync(filePath, "utf8"),
322
+ write: (next) => writeFileSync(filePath, next),
323
+ log: (message) => console.log(`jixoai-ui: ${message}`),
324
+ warn: (message) => console.warn(`jixoai-ui: ${message}`),
325
+ };
326
+ }
327
+
328
+ function runUpgradeTasks(cwd, config, lock) {
329
+ const contexts = [];
330
+ for (const [name, item] of Object.entries(lock.items)) {
331
+ for (const key of Object.keys(item.files ?? {})) {
332
+ if (existsSync(resolve(cwd, key))) contexts.push(taskContext(cwd, name, key));
333
+ }
334
+ }
335
+ for (const key of appCssCandidates(cwd, config, lock)) {
336
+ contexts.push(taskContext(cwd, null, key));
337
+ }
338
+ let ran = 0;
339
+ let skipped = 0;
340
+ for (const task of upgradeTasks) {
341
+ let taskRan = false;
342
+ for (const ctx of contexts) {
343
+ if (task.item && task.item !== ctx.item) continue;
344
+ if (!task.applies(ctx.read(), ctx)) continue;
345
+ task.run(ctx);
346
+ taskRan = true;
347
+ }
348
+ if (taskRan) ran++;
349
+ else skipped++;
350
+ }
351
+ return { ran, skipped };
352
+ }
353
+
354
+ async function runUpgrade(cwd, config) {
355
+ const registryUrl = registryUrlFor(config);
356
+ const { path: lockPath, lock } = readLock(cwd, { required: true });
357
+ const names = Object.keys(lock.items);
358
+ if (names.length === 0) {
359
+ fail(`${LOCK_NAME} records no items — install one first: \`npx jixoai-ui add <item>\``);
360
+ }
361
+
362
+ let updated = 0;
363
+ let unchanged = 0;
364
+ for (const name of names) {
365
+ let item;
366
+ try {
367
+ item = await fetchRegistryItem(registryUrl, name);
368
+ } catch (cause) {
369
+ fail(`upgrade aborted while fetching \`${name}\`: ${cause.message}`);
370
+ }
371
+ assertRegistryFiles(name, item.files);
372
+ const previous = lock.items[name]?.files ?? {};
373
+ const files = {};
374
+ for (const file of item.files) {
375
+ const key = lockInstallKey(file.target, config, cwd);
376
+ const hash = sha256(file.content);
377
+ files[key] = hash;
378
+ if (previous[key] === hash && existsSync(resolve(cwd, key))) {
379
+ unchanged++; // registry content identical to the locked install
380
+ continue;
381
+ }
382
+ const filePath = resolve(cwd, key);
383
+ mkdirSync(dirname(filePath), { recursive: true });
384
+ writeFileSync(filePath, file.content);
385
+ updated++;
386
+ console.log(`jixoai-ui: updated ${name} → ${key}`);
387
+ }
388
+ lock.items[name] = { files };
389
+ writeLock(lockPath, lock); // persist per item so an abort keeps progress
390
+ }
391
+
392
+ applyHue(themeCssPath(config, cwd), config.jixoai?.brandHue ?? DEFAULT_HUE);
393
+
394
+ const tasks = runUpgradeTasks(cwd, config, lock);
395
+
396
+ writeLock(lockPath, lock);
397
+ console.log(
398
+ `jixoai-ui: upgrade complete — updated ${updated}, unchanged ${unchanged}, ` +
399
+ `tasks ran ${tasks.ran}, skipped ${tasks.skipped}`,
400
+ );
401
+ console.log(`jixoai-ui: ${LOCK_NAME} updated → ${lockPath}`);
402
+ }
403
+
131
404
  const [command, ...rest] = process.argv.slice(2);
132
405
  const cwd = process.cwd();
133
406
 
@@ -141,6 +414,7 @@ switch (command) {
141
414
  console.log(`jixoai-ui: ${NAMESPACE} namespace + jixoai config written → ${path}`);
142
415
  shadcn(["add", `${NAMESPACE}/${THEME_ITEM}`], cwd, path, config);
143
416
  applyHue(themeCssPath(config, cwd), hue);
417
+ await recordInstalledItems(cwd, readConfig(cwd).config, [THEME_ITEM]);
144
418
  break;
145
419
  }
146
420
  case "hue": {
@@ -159,6 +433,51 @@ switch (command) {
159
433
  shadcn(["add", `${NAMESPACE}/${item}`], cwd, path, readConfig(cwd).config);
160
434
  }
161
435
  applyHue(themeCssPath(config, cwd), hue);
436
+ await recordInstalledItems(cwd, readConfig(cwd).config, rest);
437
+ break;
438
+ }
439
+ case "adopt": {
440
+ // Hand-installed sites (same-source copies) have no lock yet. adopt
441
+ // baselines the CURRENT disk content of the named items into the lock:
442
+ // the first `upgrade` afterwards diffs registry canon against this
443
+ // baseline, applies changes + hue, and the lock flips to canonical
444
+ // hashes — subsequent upgrades are fully idempotent.
445
+ const names = rest.filter((a) => !a.startsWith("--"));
446
+ if (names.length === 0) {
447
+ fail("adopt needs item names (e.g. `adopt toc jixoai-theme`) — items whose files live at their components.json targets");
448
+ }
449
+ const { config } = readConfig(cwd);
450
+ const { path, lock } = readLock(cwd, { required: false });
451
+ let recorded = 0;
452
+ for (const name of names) {
453
+ const item = await fetchRegistryItem(registryUrlFor(config), name);
454
+ assertRegistryFiles(name, item.files);
455
+ const files = {};
456
+ const missing = [];
457
+ for (const file of item.files) {
458
+ const abs = resolveInstallPath(file.target, config, cwd);
459
+ if (!existsSync(abs)) { missing.push(file.target); continue; }
460
+ files[lockInstallKey(file.target, config, cwd)] = sha256(readFileSync(abs, "utf8"));
461
+ }
462
+ if (missing.length === item.files.length) {
463
+ console.warn(`jixoai-ui: no files of ${name} found at their targets — skipped`);
464
+ continue;
465
+ }
466
+ if (missing.length > 0) {
467
+ console.warn(`jixoai-ui: ${name} partially present (missing ${missing.join(", ")}) — locked the rest; partial files will restore on next upgrade`);
468
+ }
469
+ lock.items[name] = { files };
470
+ recorded++;
471
+ console.log(`jixoai-ui: adopted ${name} (${Object.keys(files).length} file(s)) → ${path}`);
472
+ }
473
+ if (recorded === 0) fail("nothing adopted");
474
+ writeLock(path, lock);
475
+ console.log("jixoai-ui: baseline recorded — run `jixoai-ui upgrade` to sync to registry canon");
476
+ break;
477
+ }
478
+ case "upgrade": {
479
+ const { config } = readConfig(cwd);
480
+ await runUpgrade(cwd, config);
162
481
  break;
163
482
  }
164
483
  case "config": {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * jixoai-ui upgrade tasks (bin/upgrade-tasks.mjs).
3
+ *
4
+ * Contract (Owner decision, 2026-08-20): every task is IDEMPOTENT —
5
+ * `applies(content, ctx)` returns true only while the legacy pattern still
6
+ * exists, and `run(ctx)` rewrites exactly that pattern. Re-running
7
+ * `jixoai-ui upgrade` therefore converges to zero writes.
8
+ *
9
+ * Task shape: { name, item?, applies(content, ctx) → boolean, run(ctx) }.
10
+ * `item` (optional) scopes the task to one registry item; without it the
11
+ * task sees every locked file plus the site-scope candidates (app-level
12
+ * css). `ctx` exposes { cwd, item, path, filePath, content (getter),
13
+ * read(), write(next), log(msg), warn(msg) }.
14
+ */
15
+ export const upgradeTasks = [
16
+ {
17
+ name: "legacy-import-paths",
18
+ // pre-0.2.0 toc installs imported the engine/css via bare @lib or
19
+ // relative specifiers; the components now use SvelteKit $lib aliases.
20
+ applies(content) {
21
+ return content.includes("@lib/toc-engine") || content.includes("../lib/toc.css");
22
+ },
23
+ run(ctx) {
24
+ const next = ctx
25
+ .read()
26
+ .replaceAll("@lib/toc-engine", "$lib/toc-engine")
27
+ .replaceAll("../lib/toc.css", "$lib/toc.css");
28
+ ctx.write(next);
29
+ ctx.log(`task legacy-import-paths: rewrote toc imports to $lib aliases → ${ctx.path}`);
30
+ },
31
+ },
32
+ {
33
+ name: "spine-axis",
34
+ item: "toc",
35
+ // the desktop Rule Tracker spine sits ON the node axis (left: 0), not
36
+ // 2px off it. Anchored to declaration position (^ + indentation only)
37
+ // so prose comments ("spine sits left: 2px …") and other declarations
38
+ // (border-left: 2px) are never touched.
39
+ applies(content, ctx) {
40
+ return ctx.path.endsWith(".css") && /^[ \t]*left:[ \t]*2px/m.test(content);
41
+ },
42
+ run(ctx) {
43
+ const next = ctx.read().replace(
44
+ /^([ \t]*)left:[ \t]*2px/gm,
45
+ (_match, indent) => `${indent}left: 0px`,
46
+ );
47
+ ctx.write(next);
48
+ ctx.log(`task spine-axis: moved the spine onto the node axis (left: 2px → 0px) → ${ctx.path}`);
49
+ },
50
+ },
51
+ {
52
+ name: "scroll-margin-cleanup",
53
+ // DIAGNOSTIC ONLY — never edits files. scroll-margin-top on the
54
+ // anchors and scroll-padding-top on the scroll container STACK (see
55
+ // the "named line law" in toc.css), so a site carrying both doubles
56
+ // its anchor landings past the toc line. Which of the two owns the
57
+ // offset is an app-level layout decision jixoai-ui must not make for
58
+ // you, so this task only warns — and because it fixes nothing, it
59
+ // re-reports on every run until the owner removes the redundancy.
60
+ applies(content) {
61
+ return content.includes("scroll-margin-top") && content.includes("scroll-padding-top");
62
+ },
63
+ run(ctx) {
64
+ ctx.warn(
65
+ `${ctx.path} declares both scroll-margin-top and scroll-padding-top — the offsets stack and anchor jumps land past the toc line. Keep scroll-padding-top on the scroll container (the jixoai toc consumes --jx-toc-line that way) and remove the target-side scroll-margin-top. jixoai-ui never edits app-level css automatically.`,
66
+ );
67
+ },
68
+ },
69
+ ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jixoai-ui",
3
- "version": "0.1.2",
4
- "description": "Official jixoai design-language CLI: initializes the @jixoai shadcn registry namespace, manages the jixoai extension fields in components.json, and applies the per-project brand hue.",
3
+ "version": "0.2.0",
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",
7
7
  "bin": {