Files
nextlevelbuilder-ui-ux-pro-…/cli/scripts/sync-assets.mjs
Alexander ef5f5ba0e6 fix(cli): install all 7 skills via uipro init, not just the orchestrator (#362) (#387)
* fix(cli): install all 7 skills via uipro init, not just the orchestrator

`uipro init` rendered only the orchestrator (ui-ux-pro-max) and never
delivered the 6 sibling skills (banner-design, brand, design,
design-system, slides, ui-styling), so users got 1 of 7 skills (#362).

- sync-assets.mjs: bundle the 6 sub-skills into cli/assets/skills/ as
  static copies (source of truth: .claude/skills/), with sync + check
  coverage. Excludes ui-styling/canvas-fonts (~5.8MB of TTF) and
  __pycache__/.pyc cruft — a skill registers from its SKILL.md, not its
  fonts — so the bundle adds ~0.9MB, not ~6.6MB.
- template.ts: after rendering the orchestrator, install each bundled
  sub-skill as a sibling. The skills parent is derived from the
  platform's skillPath (skills/ for most, prompts/ for copilot,
  steering/ for kiro) rather than hardcoded.
- uninstall.ts: remove the sub-skills too.

Verified: check:assets in sync, tsc passes, and a per-platform install
harness delivers all 7 skills to the correct parent dir with no fonts.

Closes #362

* fix(cli): filter excluded files from target side of check:assets

check:assets filtered sourceFiles with isExcludedAssetFile but not
targetFiles, so a stray cli/assets/scripts/__pycache__/*.pyc (generated
by a local Python run) was reported as an "extra asset file" and failed
the gate. Apply the same predicate to targetFiles in both the
dirsToSync and sub-skill loops.

Verified: check:assets now passes with __pycache__/*.pyc present in the
target tree; typecheck passes.

* fix(cli): uninstall from each platform's real skills dir, not hardcoded skills/

removeSkillDir() hardcoded <folder>/skills/<name>, but the installer
places skills under each platform config's skillPath parent — copilot in
.github/prompts/, kiro in .kiro/steering/. So uninstall left those
platforms' skills (orchestrator + sub-skills) behind.

Derive the install parent from loadPlatformConfig(aiType).folderStructure
(same source the installer uses), and keep the legacy <folder>/skills/
cleanup (incl. .shared/) for older installs. Deduped via a Set.

Verified: typecheck passes; an install+uninstall harness removes all 7
skills with zero leftovers for claude (.claude/skills), copilot
(.github/prompts) and kiro (.kiro/steering).

* fix(cli): re-sync bundled sub-skills after #385 stripped ckm- names

#385 merged to main and removed the ckm- prefix from the six
.claude/skills/*/SKILL.md name fields. This branch's bundled copies
under cli/assets/skills/ still carried the old ckm- names, so after the
PR merges with main the source no longer matched the bundle and the
check-asset-sync CI gate failed (stale asset file: skills/*/SKILL.md).

Merge main and regenerate the bundle so cli/assets/skills matches the
current .claude/skills source of truth. check:assets and typecheck pass.
2026-06-25 17:33:23 +07:00

211 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import {
access,
mkdir,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..', '..');
const sourceRoot = join(repoRoot, 'src', 'ui-ux-pro-max');
const assetRoot = join(repoRoot, 'cli', 'assets');
const dirsToSync = ['data', 'scripts', 'templates'];
const checkOnly = process.argv.includes('--check');
// The 6 sibling sub-skills are bundled (as static copies) so `uipro init`
// installs all 7 skills, not just the template-rendered orchestrator. Source
// of truth is .claude/skills/ (the orchestrator ui-ux-pro-max is rendered from
// templates at install time, so it is not mirrored here).
const skillsSourceRoot = join(repoRoot, '.claude', 'skills');
const skillsAssetRoot = join(assetRoot, 'skills');
const subSkills = ['banner-design', 'brand', 'design', 'design-system', 'slides', 'ui-styling'];
// ponytail: only text is bundled. Excludes (a) heavy binary assets — the
// canvas fonts are ~5.8MB and a skill registers from its SKILL.md, not its
// fonts — and (b) Python build cruft (__pycache__/*.pyc, .coverage) that would
// otherwise be picked up from a local run.
const isExcludedAssetFile = (rel) =>
rel.split('/').some((seg) => seg === 'canvas-fonts' || seg === '__pycache__') ||
/\.(ttf|otf|woff2?|png|jpe?g|gif|ico|coverage|pyc)$/i.test(rel);
// ponytail: all synced assets are text (csv/json/md/py); normalize CRLF->LF so
// the byte hash and the on-disk copy don't drift with git autocrlf across platforms.
const toLF = (text) => text.replace(/\r\n/g, '\n');
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
function assertInsideRepo(path) {
const resolvedPath = resolve(path);
if (!resolvedPath.startsWith(repoRoot)) {
throw new Error(`Refusing to modify path outside repository: ${resolvedPath}`);
}
return resolvedPath;
}
async function listFiles(root) {
const files = [];
async function walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.push(relative(root, fullPath).replaceAll('\\', '/'));
}
}
}
await walk(root);
return files.sort();
}
async function fileHash(path) {
const content = toLF(await readFile(path, 'utf8'));
return createHash('sha256').update(content).digest('hex');
}
async function checkAssets() {
const drift = [];
for (const dir of dirsToSync) {
const sourceDir = join(sourceRoot, dir);
const targetDir = join(assetRoot, dir);
if (!(await exists(sourceDir))) {
drift.push(`missing source directory: ${relative(repoRoot, sourceDir)}`);
continue;
}
if (!(await exists(targetDir))) {
drift.push(`missing asset directory: ${relative(repoRoot, targetDir)}`);
continue;
}
const sourceFiles = (await listFiles(sourceDir)).filter((f) => !isExcludedAssetFile(f));
const targetFiles = (await listFiles(targetDir)).filter((f) => !isExcludedAssetFile(f));
const allFiles = [...new Set([...sourceFiles, ...targetFiles])].sort();
for (const file of allFiles) {
const sourcePath = join(sourceDir, file);
const targetPath = join(targetDir, file);
if (!sourceFiles.includes(file)) {
drift.push(`extra asset file: ${dir}/${file}`);
} else if (!targetFiles.includes(file)) {
drift.push(`missing asset file: ${dir}/${file}`);
} else if ((await fileHash(sourcePath)) !== (await fileHash(targetPath))) {
drift.push(`stale asset file: ${dir}/${file}`);
}
}
}
// Sub-skills (text content only; fonts/binaries intentionally excluded)
for (const name of subSkills) {
const sourceDir = join(skillsSourceRoot, name);
const targetDir = join(skillsAssetRoot, name);
if (!(await exists(sourceDir))) {
drift.push(`missing source sub-skill: ${relative(repoRoot, sourceDir)}`);
continue;
}
if (!(await exists(targetDir))) {
drift.push(`missing asset sub-skill: skills/${name}`);
continue;
}
const sourceFiles = (await listFiles(sourceDir)).filter((f) => !isExcludedAssetFile(f));
const targetFiles = (await listFiles(targetDir)).filter((f) => !isExcludedAssetFile(f));
const allFiles = [...new Set([...sourceFiles, ...targetFiles])].sort();
for (const file of allFiles) {
const sourcePath = join(sourceDir, file);
const targetPath = join(targetDir, file);
if (!sourceFiles.includes(file)) {
drift.push(`extra asset file: skills/${name}/${file}`);
} else if (!targetFiles.includes(file)) {
drift.push(`missing asset file: skills/${name}/${file}`);
} else if ((await fileHash(sourcePath)) !== (await fileHash(targetPath))) {
drift.push(`stale asset file: skills/${name}/${file}`);
}
}
}
if (drift.length > 0) {
console.error('CLI assets are out of sync with src/ui-ux-pro-max:');
for (const item of drift) {
console.error(` - ${item}`);
}
console.error('\nRun: npm run sync:assets');
process.exit(1);
}
console.log('CLI assets are in sync.');
}
async function syncAssets() {
assertInsideRepo(assetRoot);
await mkdir(assetRoot, { recursive: true });
for (const dir of dirsToSync) {
const sourceDir = join(sourceRoot, dir);
const targetDir = assertInsideRepo(join(assetRoot, dir));
if (!(await exists(sourceDir))) {
throw new Error(`Source directory does not exist: ${sourceDir}`);
}
if (await exists(targetDir)) {
await rm(targetDir, { recursive: true, force: true });
}
for (const file of await listFiles(sourceDir)) {
if (isExcludedAssetFile(file)) continue;
const targetPath = assertInsideRepo(join(targetDir, file));
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, toLF(await readFile(join(sourceDir, file), 'utf8')));
}
}
// Sub-skills: copy text content only (fonts/binaries excluded) so all 7
// skills ship in the package without bloating it with ~5.8MB of fonts.
const skillsTarget = assertInsideRepo(skillsAssetRoot);
if (await exists(skillsTarget)) {
await rm(skillsTarget, { recursive: true, force: true });
}
for (const name of subSkills) {
const sourceDir = join(skillsSourceRoot, name);
if (!(await exists(sourceDir))) {
throw new Error(`Source sub-skill does not exist: ${sourceDir}`);
}
for (const file of await listFiles(sourceDir)) {
if (isExcludedAssetFile(file)) continue;
const targetPath = assertInsideRepo(join(skillsTarget, name, file));
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, toLF(await readFile(join(sourceDir, file), 'utf8')));
}
}
console.log('Synced CLI assets from src/ui-ux-pro-max + 6 sub-skills (normalized to LF).');
}
if (checkOnly) {
await checkAssets();
} else {
await syncAssets();
}