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

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-DRttebfK.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
  }
@@ -341,6 +415,21 @@ async function scaffoldTheme(cwd) {
341
415
  action: "created"
342
416
  };
343
417
  }
418
+ const THEME_COLOR_TOKENS = new Set([...THEME_BLOCK.matchAll(/--color-([\w-]+)\s*:/g)].map((m) => m[1] ?? ""));
419
+ const COLOR_UTILITY_RE = /^(?:bg|text|border|ring|outline|fill|stroke|divide|accent|caret|decoration|shadow|from|via|to|placeholder)-(.+)$/;
420
+ function referencedThemeTokens(flattened) {
421
+ const out = /* @__PURE__ */ new Set();
422
+ for (const utilities of Object.values(flattened)) for (const raw of utilities) {
423
+ const m = (raw.split(":").pop() ?? raw).match(COLOR_UTILITY_RE);
424
+ if (!m) continue;
425
+ const name = (m[1] ?? "").replace(/\/.*$/, "");
426
+ if (THEME_COLOR_TOKENS.has(name)) out.add(name);
427
+ }
428
+ return [...out].sort();
429
+ }
430
+ function findMissingThemeTokens(css, flattened) {
431
+ return referencedThemeTokens(flattened).filter((name) => !new RegExp(`--(?:color-)?${name}\\s*:`).test(css));
432
+ }
344
433
  function isTailwindV4(cwd) {
345
434
  try {
346
435
  const pkg = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf8"));
@@ -371,7 +460,7 @@ async function wireBundler(cwd, bundler) {
371
460
  if (bundler === "next") return {
372
461
  configPath: null,
373
462
  action: "manual",
374
- snippet: `import { withShortwind } from "@shortwind/next";\n// wrap your Next config: export default withShortwind(nextConfig);`,
463
+ snippet: `import { withShortwind } from "@shortwind/next";\n// withShortwind is curried — wrap your Next config:\n// export default withShortwind()(nextConfig);`,
375
464
  reason: "Next config wiring is manual"
376
465
  };
377
466
  if (bundler === "astro") return {
@@ -423,17 +512,61 @@ function addImport(source, line) {
423
512
  return source.slice(0, lastEnd) + `\n${line}` + source.slice(lastEnd);
424
513
  }
425
514
  //#endregion
515
+ //#region src/agents-file.ts
516
+ const MARKER = "skills/shortwind/SKILL.md";
517
+ function line(skillRel) {
518
+ 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}\`.`;
519
+ }
520
+ const CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
521
+ async function wireAgentsInstructions(cwd, skillPath) {
522
+ const pointer = line(path.relative(cwd, skillPath).split(path.sep).join("/"));
523
+ let touched = null;
524
+ for (const name of CANDIDATES) {
525
+ const file = path.join(cwd, name);
526
+ if (!existsSync(file)) continue;
527
+ const current = await readFile(file, "utf8");
528
+ if (current.includes(MARKER)) {
529
+ touched ??= {
530
+ path: file,
531
+ action: "skipped"
532
+ };
533
+ continue;
534
+ }
535
+ await writeFile(file, current + (current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n") + pointer + "\n");
536
+ return {
537
+ path: file,
538
+ action: "appended"
539
+ };
540
+ }
541
+ if (touched) return touched;
542
+ const target = path.join(cwd, "AGENTS.md");
543
+ await writeFile(target, `# AGENTS.md\n\n${pointer}\n`);
544
+ return {
545
+ path: target,
546
+ action: "created"
547
+ };
548
+ }
549
+ //#endregion
426
550
  //#region src/init.ts
427
- const DEFAULT_REGISTRY = "https://shortwind.dev/registry";
551
+ const DEFAULT_REGISTRY = BUNDLED_ORIGIN;
428
552
  async function init(options) {
429
553
  const cwd = path.resolve(options.cwd);
430
- const registry = options.registry ?? "https://shortwind.dev/registry";
431
- const source = createRegistrySource(registry);
554
+ const registry = options.registry ?? DEFAULT_REGISTRY;
555
+ const source = await resolveSource(registry);
432
556
  const shape = detectProject(cwd);
433
557
  const families = await resolveFamilies(options.preset, source);
434
558
  const pkgs = pickPackages(shape.bundler);
559
+ const version = cliVersion();
560
+ const specs = version ? pkgs.map((p) => `${p}@${version}`) : pkgs;
435
561
  const installer = options.installPackages ?? defaultInstall;
436
- if (pkgs.length > 0) await installer(shape.packageManager, pkgs, cwd);
562
+ let installOk = true;
563
+ let installError = null;
564
+ if (specs.length > 0) try {
565
+ await installer(shape.packageManager, specs, cwd);
566
+ } catch (err) {
567
+ installOk = false;
568
+ installError = err instanceof Error ? err.message : String(err);
569
+ }
437
570
  const recipesDir = path.join(cwd, "recipes");
438
571
  const { installed, skipped } = await copyRecipes(source, families, recipesDir);
439
572
  await updateLockfile(recipesDir, registry, installed);
@@ -447,9 +580,12 @@ async function init(options) {
447
580
  const huskyPath = path.join(cwd, ".husky", "pre-commit");
448
581
  await installHuskyHook(huskyPath);
449
582
  const skillPath = path.join(cwd, "skills", "shortwind", "SKILL.md");
450
- await writeSkillMd(skillPath, recipesDir, families);
583
+ const skillRegistry = await writeSkillMd(skillPath, recipesDir, families);
451
584
  const theme = await scaffoldTheme(cwd);
585
+ let missingThemeTokens = [];
586
+ if (theme.action === "skipped" && theme.themePath && skillRegistry) missingThemeTokens = findMissingThemeTokens(await readFile(theme.themePath, "utf8"), skillRegistry.flattened);
452
587
  const bundlerConfig = await wireBundler(cwd, shape.bundler);
588
+ const agentsFile = await wireAgentsInstructions(cwd, skillPath);
453
589
  return {
454
590
  packageManager: shape.packageManager,
455
591
  preset: options.preset,
@@ -464,15 +600,28 @@ async function init(options) {
464
600
  skillPath,
465
601
  themePath: theme.themePath,
466
602
  themeAction: theme.action,
603
+ missingThemeTokens,
467
604
  bundlerConfigPath: bundlerConfig.configPath,
468
605
  bundlerConfigAction: bundlerConfig.action,
469
- ...bundlerConfig.snippet ? { bundlerConfigSnippet: bundlerConfig.snippet } : {}
606
+ ...bundlerConfig.snippet ? { bundlerConfigSnippet: bundlerConfig.snippet } : {},
607
+ agentsFilePath: agentsFile.path,
608
+ agentsFileAction: agentsFile.action,
609
+ installOk,
610
+ installError
470
611
  };
471
612
  }
472
613
  async function resolveFamilies(preset, source) {
473
614
  if (preset === "none") return [];
474
615
  return resolvePresetFamilies(preset, await source.loadPresets(), await source.listAllFamilies());
475
616
  }
617
+ function cliVersion() {
618
+ try {
619
+ const pkgUrl = new URL("../package.json", import.meta.url);
620
+ return JSON.parse(readFileSync(fileURLToPath(pkgUrl), "utf8")).version ?? null;
621
+ } catch {
622
+ return null;
623
+ }
624
+ }
476
625
  function pickPackages(bundler) {
477
626
  const base = ["@shortwind/tailwind"];
478
627
  switch (bundler) {
@@ -544,6 +693,7 @@ async function copyRecipes(source, families, recipesDir) {
544
693
  continue;
545
694
  }
546
695
  const body = await source.loadFamily(family);
696
+ verifyFetchedFamily(body, family);
547
697
  await writeFile(target, rewriteHeaderSha(body, computeBodySha(body)));
548
698
  installed.push(family);
549
699
  }
@@ -562,8 +712,14 @@ async function writeConfig(configPath, next) {
562
712
  await writeFile(configPath, JSON.stringify(desired, null, 2) + "\n");
563
713
  return;
564
714
  }
715
+ let current;
716
+ try {
717
+ current = JSON.parse(await readFile(configPath, "utf8"));
718
+ } catch (err) {
719
+ throw new Error(`${configPath}: invalid JSON — ${err.message}`);
720
+ }
565
721
  const merged = {
566
- ...JSON.parse(await readFile(configPath, "utf8")),
722
+ ...current !== null && typeof current === "object" && !Array.isArray(current) ? current : {},
567
723
  ...desired
568
724
  };
569
725
  await writeFile(configPath, JSON.stringify(merged, null, 2) + "\n");
@@ -595,9 +751,9 @@ async function installHuskyHook(huskyPath) {
595
751
  await writeFile(huskyPath, current.endsWith("\n") ? current + HUSKY_LINE + "\n" : current + "\nnpx shortwind build\n", { mode: 493 });
596
752
  }
597
753
  async function writeSkillMd(skillPath, recipesDir, families) {
598
- await mkdir(path.dirname(skillPath), { recursive: true });
599
754
  const allRecipes = [];
600
755
  const guidance = {};
756
+ const problems = [];
601
757
  for (const family of families) {
602
758
  const filePath = path.join(recipesDir, `${family}.css`);
603
759
  if (!existsSync(filePath)) continue;
@@ -605,23 +761,34 @@ async function writeSkillMd(skillPath, recipesDir, families) {
605
761
  if (parsed.ok) {
606
762
  allRecipes.push(...parsed.value.recipes);
607
763
  if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
608
- }
764
+ } else problems.push(`${family}.css: ${parsed.errors.map((e) => e.message).join("; ")}`);
609
765
  }
610
- let registry = {
611
- families: {},
612
- flattened: {}
613
- };
614
766
  const resolved = buildRegistry(allRecipes, { guidance });
615
- if (resolved.ok) registry = resolved.value;
616
- await writeFile(skillPath, renderSkillMarkdown(registry, { order: families }));
767
+ if (problems.length > 0 || !resolved.ok) {
768
+ const all = resolved.ok ? problems : [...problems, ...resolved.errors.map((e) => e.message)];
769
+ console.warn(`[shortwind] SKILL.md not generated — recipe errors:\n ${all.join("\n ")}`);
770
+ return null;
771
+ }
772
+ await mkdir(path.dirname(skillPath), { recursive: true });
773
+ await writeFile(skillPath, renderSkillMarkdown(resolved.value, { order: families }));
774
+ return resolved.value;
617
775
  }
618
776
  //#endregion
619
777
  //#region src/project.ts
620
778
  const DEFAULT_CONFIG = {
621
- registry: "https://shortwind.dev/registry",
779
+ registry: BUNDLED_ORIGIN,
622
780
  recipesDir: "recipes",
623
781
  outputPath: "skills/shortwind/SKILL.md"
624
782
  };
783
+ function assertConfigString(value, field, configPath) {
784
+ if (typeof value !== "string") throw new Error(`${configPath}: "${field}" must be a string`);
785
+ return value;
786
+ }
787
+ function assertWithinCwd(cwd, value, field, configPath) {
788
+ const rel = path.relative(cwd, path.resolve(cwd, value));
789
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`${configPath}: "${field}" (${JSON.stringify(value)}) must be a path inside the project directory`);
790
+ return value;
791
+ }
625
792
  async function readConfig(cwd) {
626
793
  const configPath = path.join(cwd, "shortwind.config.json");
627
794
  if (!existsSync(configPath)) return DEFAULT_CONFIG;
@@ -632,10 +799,16 @@ async function readConfig(cwd) {
632
799
  } catch (err) {
633
800
  throw new Error(`${configPath}: invalid JSON — ${err.message}`);
634
801
  }
635
- return {
802
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${configPath}: expected a JSON object`);
803
+ const merged = {
636
804
  ...DEFAULT_CONFIG,
637
805
  ...parsed
638
806
  };
807
+ return {
808
+ registry: assertConfigString(merged.registry, "registry", configPath),
809
+ recipesDir: assertWithinCwd(cwd, assertConfigString(merged.recipesDir, "recipesDir", configPath), "recipesDir", configPath),
810
+ outputPath: assertWithinCwd(cwd, assertConfigString(merged.outputPath, "outputPath", configPath), "outputPath", configPath)
811
+ };
639
812
  }
640
813
  function installedFamilies(recipesDir) {
641
814
  if (!existsSync(recipesDir)) return [];
@@ -655,24 +828,25 @@ async function regenerateSkillMd(cwd, config) {
655
828
  const recipesDir = path.join(cwd, config.recipesDir);
656
829
  const families = installedFamilies(recipesDir);
657
830
  const skillPath = path.join(cwd, config.outputPath);
658
- const { mkdir } = await import("node:fs/promises");
659
- await mkdir(path.dirname(skillPath), { recursive: true });
660
831
  const allRecipes = [];
661
832
  const guidance = {};
833
+ const problems = [];
662
834
  for (const family of families) {
663
835
  const parsed = parseRecipeFile(readFileSync(path.join(recipesDir, `${family}.css`), "utf8"), `${family}.css`);
664
836
  if (parsed.ok) {
665
837
  allRecipes.push(...parsed.value.recipes);
666
838
  if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
667
- }
839
+ } else problems.push(`${family}.css: ${parsed.errors.map((e) => e.message).join("; ")}`);
668
840
  }
669
- let registry = {
670
- families: {},
671
- flattened: {}
672
- };
673
841
  const resolved = buildRegistry(allRecipes, { guidance });
674
- if (resolved.ok) registry = resolved.value;
675
- await writeFile(skillPath, renderSkillMarkdown(registry, { order: families }));
842
+ if (problems.length > 0 || !resolved.ok) {
843
+ const all = resolved.ok ? problems : [...problems, ...resolved.errors.map((e) => e.message)];
844
+ console.warn(`[shortwind] SKILL.md not regenerated — fix these recipe errors first:\n ${all.join("\n ")}\n ${path.relative(cwd, skillPath)} left unchanged.`);
845
+ return skillPath;
846
+ }
847
+ const { mkdir } = await import("node:fs/promises");
848
+ await mkdir(path.dirname(skillPath), { recursive: true });
849
+ await writeFile(skillPath, renderSkillMarkdown(resolved.value, { order: families }));
676
850
  return skillPath;
677
851
  }
678
852
  /**
@@ -698,7 +872,7 @@ async function add(options) {
698
872
  const cwd = path.resolve(options.cwd);
699
873
  const config = await readConfig(cwd);
700
874
  const registry = options.registry ?? config.registry;
701
- const source = createRegistrySource(registry);
875
+ const source = await resolveSource(registry);
702
876
  const recipesDir = path.join(cwd, config.recipesDir);
703
877
  await mkdir(recipesDir, { recursive: true });
704
878
  const lock = await readLockfile(recipesDir);
@@ -706,6 +880,7 @@ async function add(options) {
706
880
  const requested = options.all ? await source.listAllFamilies() : options.families;
707
881
  if (options.all && options.as) throw new Error("--as cannot be combined with --all");
708
882
  if (options.as && requested.length !== 1) throw new Error("--as requires exactly one family argument");
883
+ if (options.as !== void 0) assertValidFamilyName(options.as);
709
884
  const added = [];
710
885
  const skipped = [];
711
886
  const overwritten = [];
@@ -720,6 +895,7 @@ async function add(options) {
720
895
  continue;
721
896
  }
722
897
  const sourceCss = await source.loadFamily(family);
898
+ verifyFetchedFamily(sourceCss, family);
723
899
  const renamed = options.as ? renameFamilyInSource(sourceCss, family, options.as) : sourceCss;
724
900
  const sha = computeBodySha(renamed);
725
901
  const finalCss = rewriteHeaderSha(renamed, sha);
@@ -854,11 +1030,55 @@ async function newFamily(options) {
854
1030
  };
855
1031
  }
856
1032
  //#endregion
1033
+ //#region src/commands/reseal.ts
1034
+ async function reseal(options) {
1035
+ const cwd = path.resolve(options.cwd);
1036
+ const config = await readConfig(cwd);
1037
+ const recipesDir = path.join(cwd, config.recipesDir);
1038
+ const families = options.families && options.families.length > 0 ? options.families : installedFamilies(recipesDir);
1039
+ const lock = await readLockfile(recipesDir);
1040
+ const resealed = [];
1041
+ const unchanged = [];
1042
+ const notFound = [];
1043
+ const noHeader = [];
1044
+ for (const family of families) {
1045
+ const file = path.join(recipesDir, `${family}.css`);
1046
+ if (!existsSync(file)) {
1047
+ notFound.push(family);
1048
+ continue;
1049
+ }
1050
+ const source = readFileSync(file, "utf8");
1051
+ const header = extractHeader(source);
1052
+ if (!header) {
1053
+ noHeader.push(family);
1054
+ continue;
1055
+ }
1056
+ const sha = computeBodySha(source);
1057
+ if (sha === header.sha && lock.families[family]?.sha === sha) {
1058
+ unchanged.push(family);
1059
+ continue;
1060
+ }
1061
+ await writeFile(file, rewriteHeaderSha(source, sha));
1062
+ lock.families[family] = {
1063
+ version: header.version,
1064
+ sha
1065
+ };
1066
+ resealed.push(family);
1067
+ }
1068
+ await writeLockfile(recipesDir, lock);
1069
+ return {
1070
+ resealed,
1071
+ unchanged,
1072
+ notFound,
1073
+ noHeader
1074
+ };
1075
+ }
1076
+ //#endregion
857
1077
  //#region src/commands/preset.ts
858
1078
  async function preset(options) {
859
1079
  const cwd = path.resolve(options.cwd);
860
1080
  const config = await readConfig(cwd);
861
- const source = createRegistrySource(options.registry ?? config.registry);
1081
+ const source = await resolveSource(options.registry ?? config.registry);
862
1082
  if (options.name === "none") throw new Error("Use `shortwind remove` to uninstall families; preset 'none' is for `init` only.");
863
1083
  const presets = await source.loadPresets();
864
1084
  const all = await source.listAllFamilies();
@@ -887,7 +1107,7 @@ async function ls(options) {
887
1107
  });
888
1108
  let available = [];
889
1109
  if (!options.installedOnly) {
890
- const source = createRegistrySource(options.registry ?? config.registry);
1110
+ const source = await resolveSource(options.registry ?? config.registry);
891
1111
  try {
892
1112
  available = await source.listAllFamilies();
893
1113
  } catch {
@@ -1021,6 +1241,12 @@ async function dev(options) {
1021
1241
  timer = setTimeout(() => void runBuild(false), debounceMs);
1022
1242
  };
1023
1243
  watcher.on("add", schedule).on("change", schedule).on("unlink", schedule);
1244
+ watcher.on("error", (err) => {
1245
+ status({
1246
+ kind: "error",
1247
+ message: err instanceof Error ? err.message : String(err)
1248
+ });
1249
+ });
1024
1250
  let stopped = false;
1025
1251
  const stop = async () => {
1026
1252
  if (stopped) return;
@@ -1061,7 +1287,7 @@ async function upgrade(options) {
1061
1287
  const cwd = path.resolve(options.cwd);
1062
1288
  const config = await readConfig(cwd);
1063
1289
  const registry = options.registry ?? config.registry;
1064
- const source = options.source ?? createRegistrySource(registry);
1290
+ const source = options.source ?? await resolveSource(registry);
1065
1291
  const recipesDir = path.join(cwd, config.recipesDir);
1066
1292
  const installed = installedFamilies(recipesDir);
1067
1293
  const targets = options.families && options.families.length > 0 ? options.families : installed;
@@ -1090,6 +1316,7 @@ async function upgrade(options) {
1090
1316
  let incomingBody;
1091
1317
  try {
1092
1318
  incomingBody = await source.loadFamily(family);
1319
+ verifyFetchedFamily(incomingBody, family);
1093
1320
  } catch (err) {
1094
1321
  errors.push({
1095
1322
  family,
@@ -1111,7 +1338,9 @@ async function upgrade(options) {
1111
1338
  const recordedSha = localHeader?.sha ?? "";
1112
1339
  const actualSha = computeBodySha(localBody);
1113
1340
  const lockedVersion = lock.families[family]?.version ?? localHeader?.version ?? "";
1114
- const isTouched = recordedSha !== "" && recordedSha !== actualSha;
1341
+ const recordedIsLegacy = isLegacyFingerprint(recordedSha);
1342
+ if (recordedIsLegacy) console.warn(`[shortwind] ${family}.css uses an older fingerprint format — run \`shortwind reseal\` to upgrade its seal (the recipe body is unchanged).`);
1343
+ const isTouched = recordedSha !== "" && recordedSha !== actualSha && !recordedIsLegacy;
1115
1344
  if ((isTouched ? "touched" : lockedVersion === incomingVersion ? "unchanged" : "pristine") === "unchanged" && !isTouched) {
1116
1345
  outcomes.push({
1117
1346
  family,
@@ -1208,8 +1437,9 @@ async function upgrade(options) {
1208
1437
  skillPath
1209
1438
  };
1210
1439
  }
1440
+ let atomicWriteSeq = 0;
1211
1441
  async function atomicWrite(filePath, body) {
1212
- const tmp = filePath + ".tmp";
1442
+ const tmp = `${filePath}.${process.pid}.${atomicWriteSeq++}.tmp`;
1213
1443
  const fh = await open(tmp, "w");
1214
1444
  try {
1215
1445
  await fh.writeFile(body);
@@ -1243,13 +1473,24 @@ async function verify(options) {
1243
1473
  continue;
1244
1474
  }
1245
1475
  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
- });
1476
+ if (header.sha !== actual) {
1477
+ if (isLegacyFingerprint(header.sha)) {
1478
+ issues.push({
1479
+ family,
1480
+ kind: "legacy-fingerprint",
1481
+ file: filePath,
1482
+ recorded: header.sha
1483
+ });
1484
+ continue;
1485
+ }
1486
+ issues.push({
1487
+ family,
1488
+ kind: "header-tampered",
1489
+ file: filePath,
1490
+ recorded: header.sha,
1491
+ actual
1492
+ });
1493
+ }
1253
1494
  const locked = lock.families[family];
1254
1495
  if (!locked) issues.push({
1255
1496
  family,
@@ -1479,7 +1720,7 @@ async function lint(options) {
1479
1720
  for (const token of u.tokens) {
1480
1721
  if (!token.value.startsWith("@")) continue;
1481
1722
  const name = token.value.slice(1);
1482
- if (registry.flattened[name]) usedRecipes.add(name);
1723
+ if (Object.hasOwn(registry.flattened, name)) usedRecipes.add(name);
1483
1724
  else if (enabledRules.has("recipe/unknown")) findings.push({
1484
1725
  rule: "recipe/unknown",
1485
1726
  severity: "error",
@@ -1669,7 +1910,7 @@ function checkUsageSuffixOrder(file, tokens, registry) {
1669
1910
  for (const token of tokens) {
1670
1911
  if (!token.value.startsWith("@")) continue;
1671
1912
  const name = token.value.slice(1);
1672
- if (!registry.flattened[name]) continue;
1913
+ if (!Object.hasOwn(registry.flattened, name)) continue;
1673
1914
  const meta = recipeMeta(name, familyForRecipe(registry, name));
1674
1915
  if (!meta.badOrder) continue;
1675
1916
  findings.push({
@@ -1688,7 +1929,7 @@ function checkConflictingIntent(file, tokens, registry) {
1688
1929
  for (const token of tokens) {
1689
1930
  if (!token.value.startsWith("@")) continue;
1690
1931
  const name = token.value.slice(1);
1691
- if (!registry.flattened[name]) continue;
1932
+ if (!Object.hasOwn(registry.flattened, name)) continue;
1692
1933
  const meta = recipeMeta(name, familyForRecipe(registry, name));
1693
1934
  if (!meta.intent) continue;
1694
1935
  const familyIntents = byFamily.get(meta.family) ?? /* @__PURE__ */ new Map();
@@ -1722,7 +1963,7 @@ function checkSiblingOverlap(file, tokens, registry) {
1722
1963
  for (const token of tokens) {
1723
1964
  if (!token.value.startsWith("@")) continue;
1724
1965
  const name = token.value.slice(1);
1725
- if (!registry.flattened[name]) continue;
1966
+ if (!Object.hasOwn(registry.flattened, name)) continue;
1726
1967
  const family = familyForRecipe(registry, name) ?? name.split("-")[0] ?? name;
1727
1968
  const arr = byFamily.get(family) ?? [];
1728
1969
  arr.push({
@@ -1766,6 +2007,7 @@ function checkDynamicClass(file, dynamicTokens) {
1766
2007
  const CLASS_ATTR_STR_RE = /\b(?:class|className)\s*=\s*(["'])([^"']*)\1/g;
1767
2008
  const CLASS_ATTR_BRACE_RE = /\b(?:class|className)\s*=\s*\{/g;
1768
2009
  const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\])*)\1/g;
2010
+ const CALL_EXPANDER_RE = /\b(?:cva|tv)\s*\(/g;
1769
2011
  function extractClassUsages(source) {
1770
2012
  const usages = [];
1771
2013
  for (const m of source.matchAll(CLASS_ATTR_STR_RE)) {
@@ -1804,6 +2046,28 @@ function extractClassUsages(source) {
1804
2046
  });
1805
2047
  }
1806
2048
  }
2049
+ for (const m of source.matchAll(CALL_EXPANDER_RE)) {
2050
+ const openParen = (m.index ?? 0) + m[0].length - 1;
2051
+ const close = findMatchingDelimiter(source, openParen, "(", ")");
2052
+ if (close === -1) continue;
2053
+ const inner = source.slice(openParen + 1, close);
2054
+ for (const sm of inner.matchAll(STRING_LITERAL_RE)) {
2055
+ const value = sm[2] ?? "";
2056
+ if (value.length === 0) continue;
2057
+ const literalStart = openParen + 1 + (sm.index ?? 0);
2058
+ const valueStart = literalStart + 1;
2059
+ const { tokens, dynamicTokens } = tokenizeClassString(source, value, valueStart);
2060
+ if (!tokens.some((t) => t.value.startsWith("@")) && dynamicTokens.length === 0) continue;
2061
+ usages.push({
2062
+ fileOffset: literalStart,
2063
+ valueStart,
2064
+ raw: value,
2065
+ tokens,
2066
+ dynamicTokens,
2067
+ fixable: false
2068
+ });
2069
+ }
2070
+ }
1807
2071
  return usages;
1808
2072
  }
1809
2073
  function tokenizeClassString(source, value, valueStart) {
@@ -1838,6 +2102,23 @@ function tokenizeClassString(source, value, valueStart) {
1838
2102
  };
1839
2103
  }
1840
2104
  function findMatchingBrace(source, openIdx) {
2105
+ return findMatchingDelimiter(source, openIdx, "{", "}");
2106
+ }
2107
+ function spliceRedundantTokens(raw, isRedundant) {
2108
+ const pieces = raw.split(/(\s+)/);
2109
+ const drop = new Array(pieces.length).fill(false);
2110
+ for (let i = 0; i < pieces.length; i++) {
2111
+ const piece = pieces[i] ?? "";
2112
+ if (piece.length === 0 || /^\s+$/.test(piece)) continue;
2113
+ if (piece.startsWith("@") || piece.includes("${")) continue;
2114
+ if (!isRedundant(piece)) continue;
2115
+ drop[i] = true;
2116
+ if (i > 0 && /^\s+$/.test(pieces[i - 1] ?? "")) drop[i - 1] = true;
2117
+ else if (/^\s+$/.test(pieces[i + 1] ?? "")) drop[i + 1] = true;
2118
+ }
2119
+ return pieces.filter((_, i) => !drop[i]).join("");
2120
+ }
2121
+ function findMatchingDelimiter(source, openIdx, open, close) {
1841
2122
  let depth = 1;
1842
2123
  let i = openIdx + 1;
1843
2124
  while (i < source.length && depth > 0) {
@@ -1883,8 +2164,8 @@ function findMatchingBrace(source, openIdx) {
1883
2164
  }
1884
2165
  continue;
1885
2166
  }
1886
- if (ch === "{") depth++;
1887
- else if (ch === "}") depth--;
2167
+ if (ch === open) depth++;
2168
+ else if (ch === close) depth--;
1888
2169
  i++;
1889
2170
  }
1890
2171
  return depth === 0 ? i - 1 : -1;
@@ -1916,25 +2197,18 @@ function checkRedundantUtility(file, source, registry, applyFix) {
1916
2197
  for (const t of exp) expansions.add(t);
1917
2198
  }
1918
2199
  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
- }
2200
+ for (const tok of usage.tokens) if (!tok.value.startsWith("@") && expansions.has(tok.value)) findings.push({
2201
+ rule: "recipe/no-redundant-utility",
2202
+ severity: "info",
2203
+ file,
2204
+ line: tok.line,
2205
+ column: tok.column,
2206
+ message: `${tok.value} is already included by a recipe on this element`
2207
+ });
1934
2208
  if (fixed !== null && usage.fixable) {
1935
2209
  if (usage.valueStart < cursor) continue;
1936
2210
  fixed += source.slice(cursor, usage.valueStart);
1937
- fixed += kept.join(" ");
2211
+ fixed += spliceRedundantTokens(usage.raw, (t) => expansions.has(t));
1938
2212
  cursor = usage.valueStart + usage.raw.length;
1939
2213
  }
1940
2214
  }
@@ -2001,7 +2275,7 @@ async function bench(options) {
2001
2275
  expandedLlmTokens: 0
2002
2276
  };
2003
2277
  for (const { filename, content } of filesToBench) {
2004
- const expanded = transformContent(content, registry, { mode: filename.endsWith(".html") ? "html" : "jsx" });
2278
+ const expanded = transformContent(content, registry, { mode: modeForFile(filename) });
2005
2279
  const compactUsages = extractClassUsages(content);
2006
2280
  const expandedUsages = extractClassUsages(expanded);
2007
2281
  const fileResult = {
@@ -2108,6 +2382,6 @@ function formatBenchTable(result) {
2108
2382
  return lines.join("\n");
2109
2383
  }
2110
2384
  //#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 };
2385
+ 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
2386
 
2113
- //# sourceMappingURL=bench-NKKDz3ld.js.map
2387
+ //# sourceMappingURL=bench-oy9aPOvX.js.map