@shortwind/cli 0.1.0-beta.1 → 0.1.0-beta.10

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.
@@ -1,15 +1,15 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { applyEdits, modify, parse } from "jsonc-parser";
5
- import { buildRegistry, isReservedRecipeName, parseRecipeFile, renderSkillMarkdown } from "@shortwind/core";
6
+ import { PLACEHOLDER_SHA, RECIPE_SHA_HEX_LENGTH, buildRegistry, isReservedRecipeName, normalizeRecipeBody, parseRecipeFile, renderSkillMarkdown } from "@shortwind/core";
6
7
  import { createHash } from "node:crypto";
7
- import { fileURLToPath } from "node:url";
8
8
  import { glob } from "tinyglobby";
9
9
  import chokidar from "chokidar";
10
10
  import { Tiktoken } from "js-tiktoken/lite";
11
11
  import cl100k_base from "js-tiktoken/ranks/cl100k_base";
12
- import { loadRegistryFromDir, transformContent } from "@shortwind/tailwind";
12
+ import { loadRegistryFromDir, modeForFile, transformContent } from "@shortwind/tailwind";
13
13
  //#region src/fingerprint.ts
14
14
  const HEADER_PATTERN = /^\/\*\s*shortwind:\s+(\S+)@(\S+)\s+sha:([^\s*]+)(?:\s+—\s+DO NOT EDIT THIS LINE)?\s*\*\/\s*$/;
15
15
  function extractHeader(source) {
@@ -26,12 +26,19 @@ function bodyAfterHeader(source) {
26
26
  const eol = source.indexOf("\n");
27
27
  return eol === -1 ? "" : source.slice(eol + 1);
28
28
  }
29
- function normalizeBody(body) {
30
- return body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").map((line) => line.replace(/[\t ]+$/, "")).join("\n");
31
- }
32
29
  function computeBodySha(source) {
33
- const normalized = normalizeBody(bodyAfterHeader(source));
34
- return createHash("sha256").update(normalized).digest("hex").slice(0, 6);
30
+ const normalized = normalizeRecipeBody(bodyAfterHeader(source));
31
+ return createHash("sha256").update(normalized).digest("hex").slice(0, RECIPE_SHA_HEX_LENGTH);
32
+ }
33
+ function isLegacyFingerprint(sha) {
34
+ return sha !== PLACEHOLDER_SHA && sha.length < RECIPE_SHA_HEX_LENGTH && /^[0-9a-f]+$/.test(sha);
35
+ }
36
+ function verifyFetchedFamily(source, family) {
37
+ const header = extractHeader(source);
38
+ if (!header || header.sha === PLACEHOLDER_SHA) return;
39
+ if (header.family !== family) throw new Error(`integrity check failed for "${family}": registry returned a recipe sealed as "${header.family}" — wrong family or a tampered/corrupted response`);
40
+ const actual = computeBodySha(source);
41
+ if (header.sha !== actual) throw new Error(`integrity check failed for "${family}": header sha ${header.sha} does not match content sha ${actual} — the registry response was tampered with or corrupted in transit`);
35
42
  }
36
43
  function buildHeaderLine(family, version, sha) {
37
44
  return `/* shortwind: ${family}@${version} sha:${sha} — DO NOT EDIT THIS LINE */`;
@@ -51,13 +58,20 @@ function sealRecipeFile(source, family, version) {
51
58
  }
52
59
  //#endregion
53
60
  //#region src/detect.ts
61
+ function parsePackageJson(pkgPath) {
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
65
+ } catch (err) {
66
+ throw new Error(`${pkgPath}: invalid JSON — ${err.message}`);
67
+ }
68
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
69
+ return parsed;
70
+ }
54
71
  function detectProject(cwd) {
55
72
  const pkgPath = path.join(cwd, "package.json");
56
73
  const hasPackageJson = existsSync(pkgPath);
57
- const pkg = hasPackageJson ? JSON.parse(readFileSync(pkgPath, "utf8")) : {
58
- dependencies: {},
59
- devDependencies: {}
60
- };
74
+ const pkg = hasPackageJson ? parsePackageJson(pkgPath) : {};
61
75
  const deps = {
62
76
  ...pkg.dependencies ?? {},
63
77
  ...pkg.devDependencies ?? {}
@@ -113,9 +127,64 @@ function assertValidFamilyName(family) {
113
127
  if (!FAMILY_RE.test(family)) throw new Error(`invalid family name: ${JSON.stringify(family)} (must match ${FAMILY_RE})`);
114
128
  }
115
129
  function createRegistrySource(origin) {
116
- if (origin.startsWith("http://") || origin.startsWith("https://")) return httpSource(origin);
130
+ if (origin.startsWith("http://")) {
131
+ console.warn(`[shortwind] registry origin ${origin} uses plaintext http:// — recipe content is unauthenticated and tamperable in transit; prefer https://`);
132
+ return httpSource(origin);
133
+ }
134
+ if (origin.startsWith("https://")) return httpSource(origin);
117
135
  return fileSource(origin);
118
136
  }
137
+ async function resolveSource(origin) {
138
+ if (origin && origin !== "bundled:@shortwind/catalog") return createRegistrySource(origin);
139
+ return defaultCatalogSource();
140
+ }
141
+ const CATALOG_PACKAGE = "@shortwind/catalog";
142
+ const NPM_TIMEOUT_MS = 3e3;
143
+ const FETCH_TIMEOUT_MS = 1e4;
144
+ async function defaultCatalogSource() {
145
+ try {
146
+ const cdn = httpSource(`https://cdn.jsdelivr.net/npm/${CATALOG_PACKAGE}@${await resolveCatalogVersion()}/dist/registry`);
147
+ await cdn.loadPresets();
148
+ return cdn;
149
+ } catch {
150
+ return bundledSource();
151
+ }
152
+ }
153
+ async function resolveCatalogVersion() {
154
+ const res = await fetch(`https://registry.npmjs.org/${CATALOG_PACKAGE}`, {
155
+ signal: AbortSignal.timeout(NPM_TIMEOUT_MS),
156
+ headers: { accept: "application/vnd.npm.install-v1+json" }
157
+ });
158
+ if (!res.ok) throw new Error(`npm: ${res.status}`);
159
+ const tags = (await res.json())["dist-tags"] ?? {};
160
+ const version = tags["latest"] ?? tags["beta"];
161
+ if (!version) throw new Error("no published catalog version");
162
+ return version;
163
+ }
164
+ const BUNDLED_ORIGIN = "bundled:@shortwind/catalog";
165
+ function bundledSource() {
166
+ let cache = null;
167
+ const load = () => {
168
+ cache ??= import("./catalog.generated-B_ds7MPV.js");
169
+ return cache;
170
+ };
171
+ return {
172
+ origin: BUNDLED_ORIGIN,
173
+ async loadPresets() {
174
+ return (await load()).CATALOG_PRESETS;
175
+ },
176
+ async loadFamily(family) {
177
+ assertValidFamilyName(family);
178
+ const { CATALOG_RECIPES } = await load();
179
+ const css = CATALOG_RECIPES[family];
180
+ if (css === void 0) throw new Error(`unknown family: ${family}`);
181
+ return css;
182
+ },
183
+ async listAllFamilies() {
184
+ return [...(await load()).CATALOG_FAMILIES];
185
+ }
186
+ };
187
+ }
119
188
  function fileSource(origin) {
120
189
  const root = origin.startsWith("file://") ? fileURLToPath(origin) : origin;
121
190
  return {
@@ -136,21 +205,26 @@ function fileSource(origin) {
136
205
  }
137
206
  function httpSource(origin) {
138
207
  const base = origin.replace(/\/+$/, "");
208
+ const get = (url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
209
+ let presetsCache = null;
139
210
  return {
140
211
  origin,
141
- async loadPresets() {
142
- const res = await fetch(`${base}/presets.json`);
143
- if (!res.ok) throw new Error(`presets.json: ${res.status} ${res.statusText}`);
144
- return await res.json();
212
+ loadPresets() {
213
+ presetsCache ??= (async () => {
214
+ const res = await get(`${base}/presets.json`);
215
+ if (!res.ok) throw new Error(`presets.json: ${res.status} ${res.statusText}`);
216
+ return await res.json();
217
+ })();
218
+ return presetsCache;
145
219
  },
146
220
  async loadFamily(family) {
147
221
  assertValidFamilyName(family);
148
- const res = await fetch(`${base}/recipes/${family}.css`);
222
+ const res = await get(`${base}/recipes/${family}.css`);
149
223
  if (!res.ok) throw new Error(`${family}.css: ${res.status} ${res.statusText}`);
150
224
  return res.text();
151
225
  },
152
226
  async listAllFamilies() {
153
- const res = await fetch(`${base}/index.json`);
227
+ const res = await get(`${base}/index.json`);
154
228
  if (!res.ok) throw new Error(`index.json: ${res.status} ${res.statusText}`);
155
229
  return (await res.json()).families.filter((name) => FAMILY_RE.test(name));
156
230
  }
@@ -423,17 +497,61 @@ function addImport(source, line) {
423
497
  return source.slice(0, lastEnd) + `\n${line}` + source.slice(lastEnd);
424
498
  }
425
499
  //#endregion
500
+ //#region src/agents-file.ts
501
+ const MARKER = "skills/shortwind/SKILL.md";
502
+ function line(skillRel) {
503
+ return `For UI, prefer Shortwind \`@recipe\` class names (e.g. \`@card\`, \`@btn-primary\`, \`@row\`) over raw Tailwind where a recipe fits — full catalog in \`${skillRel}\`.`;
504
+ }
505
+ const CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
506
+ async function wireAgentsInstructions(cwd, skillPath) {
507
+ const pointer = line(path.relative(cwd, skillPath).split(path.sep).join("/"));
508
+ let touched = null;
509
+ for (const name of CANDIDATES) {
510
+ const file = path.join(cwd, name);
511
+ if (!existsSync(file)) continue;
512
+ const current = await readFile(file, "utf8");
513
+ if (current.includes(MARKER)) {
514
+ touched ??= {
515
+ path: file,
516
+ action: "skipped"
517
+ };
518
+ continue;
519
+ }
520
+ await writeFile(file, current + (current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n") + pointer + "\n");
521
+ return {
522
+ path: file,
523
+ action: "appended"
524
+ };
525
+ }
526
+ if (touched) return touched;
527
+ const target = path.join(cwd, "AGENTS.md");
528
+ await writeFile(target, `# AGENTS.md\n\n${pointer}\n`);
529
+ return {
530
+ path: target,
531
+ action: "created"
532
+ };
533
+ }
534
+ //#endregion
426
535
  //#region src/init.ts
427
- const DEFAULT_REGISTRY = "https://shortwind.dev/registry";
536
+ const DEFAULT_REGISTRY = BUNDLED_ORIGIN;
428
537
  async function init(options) {
429
538
  const cwd = path.resolve(options.cwd);
430
- const registry = options.registry ?? "https://shortwind.dev/registry";
431
- const source = createRegistrySource(registry);
539
+ const registry = options.registry ?? DEFAULT_REGISTRY;
540
+ const source = await resolveSource(registry);
432
541
  const shape = detectProject(cwd);
433
542
  const families = await resolveFamilies(options.preset, source);
434
543
  const pkgs = pickPackages(shape.bundler);
544
+ const version = cliVersion();
545
+ const specs = version ? pkgs.map((p) => `${p}@${version}`) : pkgs;
435
546
  const installer = options.installPackages ?? defaultInstall;
436
- if (pkgs.length > 0) await installer(shape.packageManager, pkgs, cwd);
547
+ let installOk = true;
548
+ let installError = null;
549
+ if (specs.length > 0) try {
550
+ await installer(shape.packageManager, specs, cwd);
551
+ } catch (err) {
552
+ installOk = false;
553
+ installError = err instanceof Error ? err.message : String(err);
554
+ }
437
555
  const recipesDir = path.join(cwd, "recipes");
438
556
  const { installed, skipped } = await copyRecipes(source, families, recipesDir);
439
557
  await updateLockfile(recipesDir, registry, installed);
@@ -450,6 +568,7 @@ async function init(options) {
450
568
  await writeSkillMd(skillPath, recipesDir, families);
451
569
  const theme = await scaffoldTheme(cwd);
452
570
  const bundlerConfig = await wireBundler(cwd, shape.bundler);
571
+ const agentsFile = await wireAgentsInstructions(cwd, skillPath);
453
572
  return {
454
573
  packageManager: shape.packageManager,
455
574
  preset: options.preset,
@@ -466,13 +585,25 @@ async function init(options) {
466
585
  themeAction: theme.action,
467
586
  bundlerConfigPath: bundlerConfig.configPath,
468
587
  bundlerConfigAction: bundlerConfig.action,
469
- ...bundlerConfig.snippet ? { bundlerConfigSnippet: bundlerConfig.snippet } : {}
588
+ ...bundlerConfig.snippet ? { bundlerConfigSnippet: bundlerConfig.snippet } : {},
589
+ agentsFilePath: agentsFile.path,
590
+ agentsFileAction: agentsFile.action,
591
+ installOk,
592
+ installError
470
593
  };
471
594
  }
472
595
  async function resolveFamilies(preset, source) {
473
596
  if (preset === "none") return [];
474
597
  return resolvePresetFamilies(preset, await source.loadPresets(), await source.listAllFamilies());
475
598
  }
599
+ function cliVersion() {
600
+ try {
601
+ const pkgUrl = new URL("../package.json", import.meta.url);
602
+ return JSON.parse(readFileSync(fileURLToPath(pkgUrl), "utf8")).version ?? null;
603
+ } catch {
604
+ return null;
605
+ }
606
+ }
476
607
  function pickPackages(bundler) {
477
608
  const base = ["@shortwind/tailwind"];
478
609
  switch (bundler) {
@@ -544,6 +675,7 @@ async function copyRecipes(source, families, recipesDir) {
544
675
  continue;
545
676
  }
546
677
  const body = await source.loadFamily(family);
678
+ verifyFetchedFamily(body, family);
547
679
  await writeFile(target, rewriteHeaderSha(body, computeBodySha(body)));
548
680
  installed.push(family);
549
681
  }
@@ -562,8 +694,14 @@ async function writeConfig(configPath, next) {
562
694
  await writeFile(configPath, JSON.stringify(desired, null, 2) + "\n");
563
695
  return;
564
696
  }
697
+ let current;
698
+ try {
699
+ current = JSON.parse(await readFile(configPath, "utf8"));
700
+ } catch (err) {
701
+ throw new Error(`${configPath}: invalid JSON — ${err.message}`);
702
+ }
565
703
  const merged = {
566
- ...JSON.parse(await readFile(configPath, "utf8")),
704
+ ...current !== null && typeof current === "object" && !Array.isArray(current) ? current : {},
567
705
  ...desired
568
706
  };
569
707
  await writeFile(configPath, JSON.stringify(merged, null, 2) + "\n");
@@ -595,9 +733,9 @@ async function installHuskyHook(huskyPath) {
595
733
  await writeFile(huskyPath, current.endsWith("\n") ? current + HUSKY_LINE + "\n" : current + "\nnpx shortwind build\n", { mode: 493 });
596
734
  }
597
735
  async function writeSkillMd(skillPath, recipesDir, families) {
598
- await mkdir(path.dirname(skillPath), { recursive: true });
599
736
  const allRecipes = [];
600
737
  const guidance = {};
738
+ const problems = [];
601
739
  for (const family of families) {
602
740
  const filePath = path.join(recipesDir, `${family}.css`);
603
741
  if (!existsSync(filePath)) continue;
@@ -605,23 +743,33 @@ async function writeSkillMd(skillPath, recipesDir, families) {
605
743
  if (parsed.ok) {
606
744
  allRecipes.push(...parsed.value.recipes);
607
745
  if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
608
- }
746
+ } else problems.push(`${family}.css: ${parsed.errors.map((e) => e.message).join("; ")}`);
609
747
  }
610
- let registry = {
611
- families: {},
612
- flattened: {}
613
- };
614
748
  const resolved = buildRegistry(allRecipes, { guidance });
615
- if (resolved.ok) registry = resolved.value;
616
- await writeFile(skillPath, renderSkillMarkdown(registry, { order: families }));
749
+ if (problems.length > 0 || !resolved.ok) {
750
+ const all = resolved.ok ? problems : [...problems, ...resolved.errors.map((e) => e.message)];
751
+ console.warn(`[shortwind] SKILL.md not generated — recipe errors:\n ${all.join("\n ")}`);
752
+ return;
753
+ }
754
+ await mkdir(path.dirname(skillPath), { recursive: true });
755
+ await writeFile(skillPath, renderSkillMarkdown(resolved.value, { order: families }));
617
756
  }
618
757
  //#endregion
619
758
  //#region src/project.ts
620
759
  const DEFAULT_CONFIG = {
621
- registry: "https://shortwind.dev/registry",
760
+ registry: BUNDLED_ORIGIN,
622
761
  recipesDir: "recipes",
623
762
  outputPath: "skills/shortwind/SKILL.md"
624
763
  };
764
+ function assertConfigString(value, field, configPath) {
765
+ if (typeof value !== "string") throw new Error(`${configPath}: "${field}" must be a string`);
766
+ return value;
767
+ }
768
+ function assertWithinCwd(cwd, value, field, configPath) {
769
+ const rel = path.relative(cwd, path.resolve(cwd, value));
770
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`${configPath}: "${field}" (${JSON.stringify(value)}) must be a path inside the project directory`);
771
+ return value;
772
+ }
625
773
  async function readConfig(cwd) {
626
774
  const configPath = path.join(cwd, "shortwind.config.json");
627
775
  if (!existsSync(configPath)) return DEFAULT_CONFIG;
@@ -632,10 +780,16 @@ async function readConfig(cwd) {
632
780
  } catch (err) {
633
781
  throw new Error(`${configPath}: invalid JSON — ${err.message}`);
634
782
  }
635
- return {
783
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${configPath}: expected a JSON object`);
784
+ const merged = {
636
785
  ...DEFAULT_CONFIG,
637
786
  ...parsed
638
787
  };
788
+ return {
789
+ registry: assertConfigString(merged.registry, "registry", configPath),
790
+ recipesDir: assertWithinCwd(cwd, assertConfigString(merged.recipesDir, "recipesDir", configPath), "recipesDir", configPath),
791
+ outputPath: assertWithinCwd(cwd, assertConfigString(merged.outputPath, "outputPath", configPath), "outputPath", configPath)
792
+ };
639
793
  }
640
794
  function installedFamilies(recipesDir) {
641
795
  if (!existsSync(recipesDir)) return [];
@@ -655,24 +809,25 @@ async function regenerateSkillMd(cwd, config) {
655
809
  const recipesDir = path.join(cwd, config.recipesDir);
656
810
  const families = installedFamilies(recipesDir);
657
811
  const skillPath = path.join(cwd, config.outputPath);
658
- const { mkdir } = await import("node:fs/promises");
659
- await mkdir(path.dirname(skillPath), { recursive: true });
660
812
  const allRecipes = [];
661
813
  const guidance = {};
814
+ const problems = [];
662
815
  for (const family of families) {
663
816
  const parsed = parseRecipeFile(readFileSync(path.join(recipesDir, `${family}.css`), "utf8"), `${family}.css`);
664
817
  if (parsed.ok) {
665
818
  allRecipes.push(...parsed.value.recipes);
666
819
  if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
667
- }
820
+ } else problems.push(`${family}.css: ${parsed.errors.map((e) => e.message).join("; ")}`);
668
821
  }
669
- let registry = {
670
- families: {},
671
- flattened: {}
672
- };
673
822
  const resolved = buildRegistry(allRecipes, { guidance });
674
- if (resolved.ok) registry = resolved.value;
675
- await writeFile(skillPath, renderSkillMarkdown(registry, { order: families }));
823
+ if (problems.length > 0 || !resolved.ok) {
824
+ const all = resolved.ok ? problems : [...problems, ...resolved.errors.map((e) => e.message)];
825
+ console.warn(`[shortwind] SKILL.md not regenerated — fix these recipe errors first:\n ${all.join("\n ")}\n ${path.relative(cwd, skillPath)} left unchanged.`);
826
+ return skillPath;
827
+ }
828
+ const { mkdir } = await import("node:fs/promises");
829
+ await mkdir(path.dirname(skillPath), { recursive: true });
830
+ await writeFile(skillPath, renderSkillMarkdown(resolved.value, { order: families }));
676
831
  return skillPath;
677
832
  }
678
833
  /**
@@ -698,7 +853,7 @@ async function add(options) {
698
853
  const cwd = path.resolve(options.cwd);
699
854
  const config = await readConfig(cwd);
700
855
  const registry = options.registry ?? config.registry;
701
- const source = createRegistrySource(registry);
856
+ const source = await resolveSource(registry);
702
857
  const recipesDir = path.join(cwd, config.recipesDir);
703
858
  await mkdir(recipesDir, { recursive: true });
704
859
  const lock = await readLockfile(recipesDir);
@@ -706,6 +861,7 @@ async function add(options) {
706
861
  const requested = options.all ? await source.listAllFamilies() : options.families;
707
862
  if (options.all && options.as) throw new Error("--as cannot be combined with --all");
708
863
  if (options.as && requested.length !== 1) throw new Error("--as requires exactly one family argument");
864
+ if (options.as !== void 0) assertValidFamilyName(options.as);
709
865
  const added = [];
710
866
  const skipped = [];
711
867
  const overwritten = [];
@@ -720,6 +876,7 @@ async function add(options) {
720
876
  continue;
721
877
  }
722
878
  const sourceCss = await source.loadFamily(family);
879
+ verifyFetchedFamily(sourceCss, family);
723
880
  const renamed = options.as ? renameFamilyInSource(sourceCss, family, options.as) : sourceCss;
724
881
  const sha = computeBodySha(renamed);
725
882
  const finalCss = rewriteHeaderSha(renamed, sha);
@@ -854,11 +1011,55 @@ async function newFamily(options) {
854
1011
  };
855
1012
  }
856
1013
  //#endregion
1014
+ //#region src/commands/reseal.ts
1015
+ async function reseal(options) {
1016
+ const cwd = path.resolve(options.cwd);
1017
+ const config = await readConfig(cwd);
1018
+ const recipesDir = path.join(cwd, config.recipesDir);
1019
+ const families = options.families && options.families.length > 0 ? options.families : installedFamilies(recipesDir);
1020
+ const lock = await readLockfile(recipesDir);
1021
+ const resealed = [];
1022
+ const unchanged = [];
1023
+ const notFound = [];
1024
+ const noHeader = [];
1025
+ for (const family of families) {
1026
+ const file = path.join(recipesDir, `${family}.css`);
1027
+ if (!existsSync(file)) {
1028
+ notFound.push(family);
1029
+ continue;
1030
+ }
1031
+ const source = readFileSync(file, "utf8");
1032
+ const header = extractHeader(source);
1033
+ if (!header) {
1034
+ noHeader.push(family);
1035
+ continue;
1036
+ }
1037
+ const sha = computeBodySha(source);
1038
+ if (sha === header.sha && lock.families[family]?.sha === sha) {
1039
+ unchanged.push(family);
1040
+ continue;
1041
+ }
1042
+ await writeFile(file, rewriteHeaderSha(source, sha));
1043
+ lock.families[family] = {
1044
+ version: header.version,
1045
+ sha
1046
+ };
1047
+ resealed.push(family);
1048
+ }
1049
+ await writeLockfile(recipesDir, lock);
1050
+ return {
1051
+ resealed,
1052
+ unchanged,
1053
+ notFound,
1054
+ noHeader
1055
+ };
1056
+ }
1057
+ //#endregion
857
1058
  //#region src/commands/preset.ts
858
1059
  async function preset(options) {
859
1060
  const cwd = path.resolve(options.cwd);
860
1061
  const config = await readConfig(cwd);
861
- const source = createRegistrySource(options.registry ?? config.registry);
1062
+ const source = await resolveSource(options.registry ?? config.registry);
862
1063
  if (options.name === "none") throw new Error("Use `shortwind remove` to uninstall families; preset 'none' is for `init` only.");
863
1064
  const presets = await source.loadPresets();
864
1065
  const all = await source.listAllFamilies();
@@ -887,7 +1088,7 @@ async function ls(options) {
887
1088
  });
888
1089
  let available = [];
889
1090
  if (!options.installedOnly) {
890
- const source = createRegistrySource(options.registry ?? config.registry);
1091
+ const source = await resolveSource(options.registry ?? config.registry);
891
1092
  try {
892
1093
  available = await source.listAllFamilies();
893
1094
  } catch {
@@ -1021,6 +1222,12 @@ async function dev(options) {
1021
1222
  timer = setTimeout(() => void runBuild(false), debounceMs);
1022
1223
  };
1023
1224
  watcher.on("add", schedule).on("change", schedule).on("unlink", schedule);
1225
+ watcher.on("error", (err) => {
1226
+ status({
1227
+ kind: "error",
1228
+ message: err instanceof Error ? err.message : String(err)
1229
+ });
1230
+ });
1024
1231
  let stopped = false;
1025
1232
  const stop = async () => {
1026
1233
  if (stopped) return;
@@ -1061,7 +1268,7 @@ async function upgrade(options) {
1061
1268
  const cwd = path.resolve(options.cwd);
1062
1269
  const config = await readConfig(cwd);
1063
1270
  const registry = options.registry ?? config.registry;
1064
- const source = options.source ?? createRegistrySource(registry);
1271
+ const source = options.source ?? await resolveSource(registry);
1065
1272
  const recipesDir = path.join(cwd, config.recipesDir);
1066
1273
  const installed = installedFamilies(recipesDir);
1067
1274
  const targets = options.families && options.families.length > 0 ? options.families : installed;
@@ -1090,6 +1297,7 @@ async function upgrade(options) {
1090
1297
  let incomingBody;
1091
1298
  try {
1092
1299
  incomingBody = await source.loadFamily(family);
1300
+ verifyFetchedFamily(incomingBody, family);
1093
1301
  } catch (err) {
1094
1302
  errors.push({
1095
1303
  family,
@@ -1111,7 +1319,9 @@ async function upgrade(options) {
1111
1319
  const recordedSha = localHeader?.sha ?? "";
1112
1320
  const actualSha = computeBodySha(localBody);
1113
1321
  const lockedVersion = lock.families[family]?.version ?? localHeader?.version ?? "";
1114
- const isTouched = recordedSha !== "" && recordedSha !== actualSha;
1322
+ const recordedIsLegacy = isLegacyFingerprint(recordedSha);
1323
+ if (recordedIsLegacy) console.warn(`[shortwind] ${family}.css uses an older fingerprint format — run \`shortwind reseal\` to upgrade its seal (the recipe body is unchanged).`);
1324
+ const isTouched = recordedSha !== "" && recordedSha !== actualSha && !recordedIsLegacy;
1115
1325
  if ((isTouched ? "touched" : lockedVersion === incomingVersion ? "unchanged" : "pristine") === "unchanged" && !isTouched) {
1116
1326
  outcomes.push({
1117
1327
  family,
@@ -1208,8 +1418,9 @@ async function upgrade(options) {
1208
1418
  skillPath
1209
1419
  };
1210
1420
  }
1421
+ let atomicWriteSeq = 0;
1211
1422
  async function atomicWrite(filePath, body) {
1212
- const tmp = filePath + ".tmp";
1423
+ const tmp = `${filePath}.${process.pid}.${atomicWriteSeq++}.tmp`;
1213
1424
  const fh = await open(tmp, "w");
1214
1425
  try {
1215
1426
  await fh.writeFile(body);
@@ -1243,13 +1454,24 @@ async function verify(options) {
1243
1454
  continue;
1244
1455
  }
1245
1456
  const actual = computeBodySha(source);
1246
- if (header.sha !== actual) issues.push({
1247
- family,
1248
- kind: "header-tampered",
1249
- file: filePath,
1250
- recorded: header.sha,
1251
- actual
1252
- });
1457
+ if (header.sha !== actual) {
1458
+ if (isLegacyFingerprint(header.sha)) {
1459
+ issues.push({
1460
+ family,
1461
+ kind: "legacy-fingerprint",
1462
+ file: filePath,
1463
+ recorded: header.sha
1464
+ });
1465
+ continue;
1466
+ }
1467
+ issues.push({
1468
+ family,
1469
+ kind: "header-tampered",
1470
+ file: filePath,
1471
+ recorded: header.sha,
1472
+ actual
1473
+ });
1474
+ }
1253
1475
  const locked = lock.families[family];
1254
1476
  if (!locked) issues.push({
1255
1477
  family,
@@ -1479,7 +1701,7 @@ async function lint(options) {
1479
1701
  for (const token of u.tokens) {
1480
1702
  if (!token.value.startsWith("@")) continue;
1481
1703
  const name = token.value.slice(1);
1482
- if (registry.flattened[name]) usedRecipes.add(name);
1704
+ if (Object.hasOwn(registry.flattened, name)) usedRecipes.add(name);
1483
1705
  else if (enabledRules.has("recipe/unknown")) findings.push({
1484
1706
  rule: "recipe/unknown",
1485
1707
  severity: "error",
@@ -1669,7 +1891,7 @@ function checkUsageSuffixOrder(file, tokens, registry) {
1669
1891
  for (const token of tokens) {
1670
1892
  if (!token.value.startsWith("@")) continue;
1671
1893
  const name = token.value.slice(1);
1672
- if (!registry.flattened[name]) continue;
1894
+ if (!Object.hasOwn(registry.flattened, name)) continue;
1673
1895
  const meta = recipeMeta(name, familyForRecipe(registry, name));
1674
1896
  if (!meta.badOrder) continue;
1675
1897
  findings.push({
@@ -1688,7 +1910,7 @@ function checkConflictingIntent(file, tokens, registry) {
1688
1910
  for (const token of tokens) {
1689
1911
  if (!token.value.startsWith("@")) continue;
1690
1912
  const name = token.value.slice(1);
1691
- if (!registry.flattened[name]) continue;
1913
+ if (!Object.hasOwn(registry.flattened, name)) continue;
1692
1914
  const meta = recipeMeta(name, familyForRecipe(registry, name));
1693
1915
  if (!meta.intent) continue;
1694
1916
  const familyIntents = byFamily.get(meta.family) ?? /* @__PURE__ */ new Map();
@@ -1722,7 +1944,7 @@ function checkSiblingOverlap(file, tokens, registry) {
1722
1944
  for (const token of tokens) {
1723
1945
  if (!token.value.startsWith("@")) continue;
1724
1946
  const name = token.value.slice(1);
1725
- if (!registry.flattened[name]) continue;
1947
+ if (!Object.hasOwn(registry.flattened, name)) continue;
1726
1948
  const family = familyForRecipe(registry, name) ?? name.split("-")[0] ?? name;
1727
1949
  const arr = byFamily.get(family) ?? [];
1728
1950
  arr.push({
@@ -1766,6 +1988,7 @@ function checkDynamicClass(file, dynamicTokens) {
1766
1988
  const CLASS_ATTR_STR_RE = /\b(?:class|className)\s*=\s*(["'])([^"']*)\1/g;
1767
1989
  const CLASS_ATTR_BRACE_RE = /\b(?:class|className)\s*=\s*\{/g;
1768
1990
  const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\])*)\1/g;
1991
+ const CALL_EXPANDER_RE = /\b(?:cva|tv)\s*\(/g;
1769
1992
  function extractClassUsages(source) {
1770
1993
  const usages = [];
1771
1994
  for (const m of source.matchAll(CLASS_ATTR_STR_RE)) {
@@ -1804,6 +2027,28 @@ function extractClassUsages(source) {
1804
2027
  });
1805
2028
  }
1806
2029
  }
2030
+ for (const m of source.matchAll(CALL_EXPANDER_RE)) {
2031
+ const openParen = (m.index ?? 0) + m[0].length - 1;
2032
+ const close = findMatchingDelimiter(source, openParen, "(", ")");
2033
+ if (close === -1) continue;
2034
+ const inner = source.slice(openParen + 1, close);
2035
+ for (const sm of inner.matchAll(STRING_LITERAL_RE)) {
2036
+ const value = sm[2] ?? "";
2037
+ if (value.length === 0) continue;
2038
+ const literalStart = openParen + 1 + (sm.index ?? 0);
2039
+ const valueStart = literalStart + 1;
2040
+ const { tokens, dynamicTokens } = tokenizeClassString(source, value, valueStart);
2041
+ if (!tokens.some((t) => t.value.startsWith("@")) && dynamicTokens.length === 0) continue;
2042
+ usages.push({
2043
+ fileOffset: literalStart,
2044
+ valueStart,
2045
+ raw: value,
2046
+ tokens,
2047
+ dynamicTokens,
2048
+ fixable: false
2049
+ });
2050
+ }
2051
+ }
1807
2052
  return usages;
1808
2053
  }
1809
2054
  function tokenizeClassString(source, value, valueStart) {
@@ -1838,6 +2083,23 @@ function tokenizeClassString(source, value, valueStart) {
1838
2083
  };
1839
2084
  }
1840
2085
  function findMatchingBrace(source, openIdx) {
2086
+ return findMatchingDelimiter(source, openIdx, "{", "}");
2087
+ }
2088
+ function spliceRedundantTokens(raw, isRedundant) {
2089
+ const pieces = raw.split(/(\s+)/);
2090
+ const drop = new Array(pieces.length).fill(false);
2091
+ for (let i = 0; i < pieces.length; i++) {
2092
+ const piece = pieces[i] ?? "";
2093
+ if (piece.length === 0 || /^\s+$/.test(piece)) continue;
2094
+ if (piece.startsWith("@") || piece.includes("${")) continue;
2095
+ if (!isRedundant(piece)) continue;
2096
+ drop[i] = true;
2097
+ if (i > 0 && /^\s+$/.test(pieces[i - 1] ?? "")) drop[i - 1] = true;
2098
+ else if (/^\s+$/.test(pieces[i + 1] ?? "")) drop[i + 1] = true;
2099
+ }
2100
+ return pieces.filter((_, i) => !drop[i]).join("");
2101
+ }
2102
+ function findMatchingDelimiter(source, openIdx, open, close) {
1841
2103
  let depth = 1;
1842
2104
  let i = openIdx + 1;
1843
2105
  while (i < source.length && depth > 0) {
@@ -1883,8 +2145,8 @@ function findMatchingBrace(source, openIdx) {
1883
2145
  }
1884
2146
  continue;
1885
2147
  }
1886
- if (ch === "{") depth++;
1887
- else if (ch === "}") depth--;
2148
+ if (ch === open) depth++;
2149
+ else if (ch === close) depth--;
1888
2150
  i++;
1889
2151
  }
1890
2152
  return depth === 0 ? i - 1 : -1;
@@ -1916,25 +2178,18 @@ function checkRedundantUtility(file, source, registry, applyFix) {
1916
2178
  for (const t of exp) expansions.add(t);
1917
2179
  }
1918
2180
  if (expansions.size === 0) continue;
1919
- const kept = [];
1920
- for (const tok of usage.tokens) {
1921
- if (!tok.value.startsWith("@") && expansions.has(tok.value)) {
1922
- findings.push({
1923
- rule: "recipe/no-redundant-utility",
1924
- severity: "info",
1925
- file,
1926
- line: tok.line,
1927
- column: tok.column,
1928
- message: `${tok.value} is already included by a recipe on this element`
1929
- });
1930
- continue;
1931
- }
1932
- kept.push(tok.value);
1933
- }
2181
+ for (const tok of usage.tokens) if (!tok.value.startsWith("@") && expansions.has(tok.value)) findings.push({
2182
+ rule: "recipe/no-redundant-utility",
2183
+ severity: "info",
2184
+ file,
2185
+ line: tok.line,
2186
+ column: tok.column,
2187
+ message: `${tok.value} is already included by a recipe on this element`
2188
+ });
1934
2189
  if (fixed !== null && usage.fixable) {
1935
2190
  if (usage.valueStart < cursor) continue;
1936
2191
  fixed += source.slice(cursor, usage.valueStart);
1937
- fixed += kept.join(" ");
2192
+ fixed += spliceRedundantTokens(usage.raw, (t) => expansions.has(t));
1938
2193
  cursor = usage.valueStart + usage.raw.length;
1939
2194
  }
1940
2195
  }
@@ -2001,7 +2256,7 @@ async function bench(options) {
2001
2256
  expandedLlmTokens: 0
2002
2257
  };
2003
2258
  for (const { filename, content } of filesToBench) {
2004
- const expanded = transformContent(content, registry, { mode: filename.endsWith(".html") ? "html" : "jsx" });
2259
+ const expanded = transformContent(content, registry, { mode: modeForFile(filename) });
2005
2260
  const compactUsages = extractClassUsages(content);
2006
2261
  const expandedUsages = extractClassUsages(expanded);
2007
2262
  const fileResult = {
@@ -2108,6 +2363,6 @@ function formatBenchTable(result) {
2108
2363
  return lines.join("\n");
2109
2364
  }
2110
2365
  //#endregion
2111
- export { extractHeader as A, readLockfile as C, detectProject as D, resolvePresetFamilies as E, rewriteHeaderSha as M, sealRecipeFile as N, buildHeaderLine as O, init as S, createRegistrySource as T, newFamily as _, formatFindingsText as a, renameFamilyInSource as b, UpgradeError as c, BuildError as d, build as f, NewFamilyError as g, preset as h, extractClassUsages as i, normalizeBody as j, computeBodySha as k, upgrade as l, ls as m, formatBenchTable as n, lint as o, formatLsText as p, ALL_RULES as r, verify as s, bench as t, dev as u, remove as v, writeLockfile as w, DEFAULT_REGISTRY as x, add as y };
2366
+ export { buildHeaderLine as A, cliVersion as C, createRegistrySource as D, writeLockfile as E, verifyFetchedFamily as F, extractHeader as M, rewriteHeaderSha as N, resolvePresetFamilies as O, sealRecipeFile as P, DEFAULT_REGISTRY as S, readLockfile as T, NewFamilyError as _, formatFindingsText as a, add as b, UpgradeError as c, BuildError as d, build as f, reseal as g, preset as h, extractClassUsages as i, computeBodySha as j, detectProject as k, upgrade as l, ls as m, formatBenchTable as n, lint as o, formatLsText as p, ALL_RULES as r, verify as s, bench as t, dev as u, newFamily as v, init as w, renameFamilyInSource as x, remove as y };
2112
2367
 
2113
- //# sourceMappingURL=bench-NKKDz3ld.js.map
2368
+ //# sourceMappingURL=bench-BGTQAha8.js.map