@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.
- package/dist/{bench-NKKDz3ld.js → bench-BGTQAha8.js} +337 -82
- package/dist/bench-BGTQAha8.js.map +1 -0
- package/dist/bin.js +41 -5
- package/dist/bin.js.map +1 -1
- package/dist/catalog.generated-B_ds7MPV.js +83 -0
- package/dist/catalog.generated-B_ds7MPV.js.map +1 -0
- package/dist/index.d.ts +28 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/package.json +13 -3
- package/dist/bench-NKKDz3ld.js.map +0 -1
|
@@ -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 =
|
|
34
|
-
return createHash("sha256").update(normalized).digest("hex").slice(0,
|
|
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 ?
|
|
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://")
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 ??
|
|
431
|
-
const source =
|
|
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
|
-
|
|
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
|
-
...
|
|
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 (
|
|
616
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
675
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ??
|
|
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
|
|
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
|
|
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)
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ===
|
|
1887
|
-
else if (ch ===
|
|
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
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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 +=
|
|
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
|
|
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 {
|
|
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-
|
|
2368
|
+
//# sourceMappingURL=bench-BGTQAha8.js.map
|