Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions cli/assets/scripts/design_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,41 @@
import csv
import json
import os
import re
from datetime import datetime
from pathlib import Path
from core import search, DATA_DIR


# ============ PATH SANITIZATION ============
def _sanitize_path_component(value: str, label: str = "value") -> str:
"""Validate and sanitize a string used as a file/directory name component.

Raises ValueError for path traversal or separator characters so that
user-supplied --page / --project-name / --output-dir values cannot escape
the intended output directory.
"""
if not value:
return "default"
if '\x00' in value:
raise ValueError(f"Invalid {label}: contains null byte")
if '/' in value or '\\' in value:
raise ValueError(f"Invalid {label}: must not contain path separators")
if '..' in value:
raise ValueError(f"Invalid {label}: must not contain '..'")
sanitized = re.sub(r'[<>:"|?*]', '-', value).strip()
if not sanitized:
raise ValueError(f"Invalid {label}: empty after sanitization")
return sanitized


def _validate_output_dir(output_dir: str) -> Path:
"""Resolve output directory path and reject null bytes."""
if '\x00' in output_dir:
raise ValueError("Invalid output directory: contains null byte")
return Path(output_dir).resolve()


# ============ CONFIGURATION ============
REASONING_FILE = "ui-reasoning.csv"

Expand Down Expand Up @@ -571,12 +601,13 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str
Returns:
dict with created file paths and status
"""
base_dir = Path(output_dir) if output_dir else Path.cwd()
base_dir = _validate_output_dir(output_dir) if output_dir else Path.cwd()

# Use project name for project-specific folder
project_name = design_system.get("project_name", "default")
project_slug = project_name.lower().replace(' ', '-')

raw_slug = project_name.lower().replace(' ', '-')
project_slug = _sanitize_path_component(raw_slug, "project name")

design_system_dir = base_dir / "design-system" / project_slug
pages_dir = design_system_dir / "pages"

Expand All @@ -596,7 +627,8 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str

# If page is specified, create page override file with intelligent content
if page:
page_file = pages_dir / f"{page.lower().replace(' ', '-')}.md"
sanitized_page = _sanitize_path_component(page.lower().replace(' ', '-'), "page name")
page_file = pages_dir / f"{sanitized_page}.md"
page_content = format_page_override_md(design_system, page, page_query)
with open(page_file, 'w', encoding='utf-8') as f:
f.write(page_content)
Expand Down
37 changes: 21 additions & 16 deletions cli/assets/scripts/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
python search.py "<query>" --design-system [-p "Project Name"]
python search.py "<query>" --design-system --persist [-p "Project Name"] [--page "dashboard"]

Domains: style, prompt, color, chart, landing, product, ux, typography
Stacks: html-tailwind, react, nextjs
Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts
Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs

Persistence (Master + Overrides pattern):
--persist Save design system to design-system/MASTER.md
Expand All @@ -27,7 +27,7 @@
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')


def format_output(result):
def format_output(result, max_length: int = 300):
"""Format results for Claude consumption (token-optimized)"""
if "error" in result:
return f"Error: {result['error']}"
Expand All @@ -45,8 +45,8 @@ def format_output(result):
output.append(f"### Result {i}")
for key, value in row.items():
value_str = str(value)
if len(value_str) > 300:
value_str = value_str[:300] + "..."
if max_length > 0 and len(value_str) > max_length:
value_str = value_str[:max_length] + "..."
output.append(f"- **{key}:** {value_str}")
output.append("")

Expand All @@ -57,8 +57,9 @@ def format_output(result):
parser = argparse.ArgumentParser(description="UI Pro Max Search")
parser.add_argument("query", help="Search query")
parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain")
parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help="Stack-specific search (html-tailwind, react, nextjs)")
parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help=f"Stack-specific search. Available: {', '.join(AVAILABLE_STACKS)}")
parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)")
parser.add_argument("--max-length", "-l", type=int, default=300, help="Max characters per field value (default: 300, 0 = unlimited)")
parser.add_argument("--json", action="store_true", help="Output as JSON")
# Design system generation
parser.add_argument("--design-system", "-ds", action="store_true", help="Generate complete design system recommendation")
Expand All @@ -73,14 +74,18 @@ def format_output(result):

# Design system takes priority
if args.design_system:
result = generate_design_system(
args.query,
args.project_name,
args.format,
persist=args.persist,
page=args.page,
output_dir=args.output_dir
)
try:
result = generate_design_system(
args.query,
args.project_name,
args.format,
persist=args.persist,
page=args.page,
output_dir=args.output_dir
)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(result)

# Print persistence confirmation
Expand All @@ -103,12 +108,12 @@ def format_output(result):
import json
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(format_output(result))
print(format_output(result, args.max_length))
# Domain search
else:
result = search(args.query, args.domain, args.max_results)
if args.json:
import json
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(format_output(result))
print(format_output(result, args.max_length))
3 changes: 3 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
],
"author": "",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"commander": "^12.1.0",
"chalk": "^5.3.0",
Expand Down
13 changes: 13 additions & 0 deletions cli/src/utils/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,20 @@ const execAsync = promisify(exec);

const EXCLUDED_FILES = ['settings.local.json'];

/**
* Reject paths containing shell-special characters before they reach execAsync.
* All paths used here are system-generated (mkdtemp), but we validate defensively
* to prevent injection if the call site ever changes.
*/
function assertSafeShellPath(p: string): void {
if (/['";`$|&<>\x00]/.test(p)) {
throw new Error(`Path contains characters unsafe for shell interpolation: ${p}`);
}
}

export async function extractZip(zipPath: string, destDir: string): Promise<void> {
assertSafeShellPath(zipPath);
assertSafeShellPath(destDir);
try {
const isWindows = process.platform === 'win32';
if (isWindows) {
Expand Down
16 changes: 16 additions & 0 deletions cli/src/utils/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import type { Release } from '../types/index.js';

const REPO_OWNER = 'nextlevelbuilder';
Expand Down Expand Up @@ -69,6 +70,18 @@ export async function getLatestRelease(): Promise<Release> {
return response.json();
}

function verifyZipMagicBytes(buffer: ArrayBuffer): void {
const bytes = new Uint8Array(buffer);
// ZIP local file header signature: PK\x03\x04 (0x504B0304)
if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4B || bytes[2] !== 0x03 || bytes[3] !== 0x04) {
throw new GitHubDownloadError('Downloaded file is not a valid ZIP archive (bad magic bytes)');
}
}

function computeSHA256(buffer: ArrayBuffer): string {
return createHash('sha256').update(Buffer.from(buffer)).digest('hex');
}

export async function downloadRelease(url: string, dest: string): Promise<void> {
const response = await fetch(url, {
headers: {
Expand All @@ -84,6 +97,9 @@ export async function downloadRelease(url: string, dest: string): Promise<void>
}

const buffer = await response.arrayBuffer();
verifyZipMagicBytes(buffer);
const sha256 = computeSHA256(buffer);
process.stderr.write(`[uipro] Asset SHA256: ${sha256}\n`);
await writeFile(dest, Buffer.from(buffer));
}

Expand Down
42 changes: 37 additions & 5 deletions src/ui-ux-pro-max/scripts/design_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,41 @@
import csv
import json
import os
import re
from datetime import datetime
from pathlib import Path
from core import search, DATA_DIR


# ============ PATH SANITIZATION ============
def _sanitize_path_component(value: str, label: str = "value") -> str:
"""Validate and sanitize a string used as a file/directory name component.

Raises ValueError for path traversal or separator characters so that
user-supplied --page / --project-name / --output-dir values cannot escape
the intended output directory.
"""
if not value:
return "default"
if '\x00' in value:
raise ValueError(f"Invalid {label}: contains null byte")
if '/' in value or '\\' in value:
raise ValueError(f"Invalid {label}: must not contain path separators")
if '..' in value:
raise ValueError(f"Invalid {label}: must not contain '..'")
sanitized = re.sub(r'[<>:"|?*]', '-', value).strip()
if not sanitized:
raise ValueError(f"Invalid {label}: empty after sanitization")
return sanitized


def _validate_output_dir(output_dir: str) -> Path:
"""Resolve output directory path and reject null bytes."""
if '\x00' in output_dir:
raise ValueError("Invalid output directory: contains null byte")
return Path(output_dir).resolve()


# ============ CONFIGURATION ============
REASONING_FILE = "ui-reasoning.csv"

Expand Down Expand Up @@ -571,12 +601,13 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str
Returns:
dict with created file paths and status
"""
base_dir = Path(output_dir) if output_dir else Path.cwd()
base_dir = _validate_output_dir(output_dir) if output_dir else Path.cwd()

# Use project name for project-specific folder
project_name = design_system.get("project_name", "default")
project_slug = project_name.lower().replace(' ', '-')

raw_slug = project_name.lower().replace(' ', '-')
project_slug = _sanitize_path_component(raw_slug, "project name")

design_system_dir = base_dir / "design-system" / project_slug
pages_dir = design_system_dir / "pages"

Expand All @@ -596,7 +627,8 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str

# If page is specified, create page override file with intelligent content
if page:
page_file = pages_dir / f"{page.lower().replace(' ', '-')}.md"
sanitized_page = _sanitize_path_component(page.lower().replace(' ', '-'), "page name")
page_file = pages_dir / f"{sanitized_page}.md"
page_content = format_page_override_md(design_system, page, page_query)
with open(page_file, 'w', encoding='utf-8') as f:
f.write(page_content)
Expand Down
31 changes: 18 additions & 13 deletions src/ui-ux-pro-max/scripts/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')


def format_output(result):
def format_output(result, max_length: int = 300):
"""Format results for Claude consumption (token-optimized)"""
if "error" in result:
return f"Error: {result['error']}"
Expand All @@ -45,8 +45,8 @@ def format_output(result):
output.append(f"### Result {i}")
for key, value in row.items():
value_str = str(value)
if len(value_str) > 300:
value_str = value_str[:300] + "..."
if max_length > 0 and len(value_str) > max_length:
value_str = value_str[:max_length] + "..."
output.append(f"- **{key}:** {value_str}")
output.append("")

Expand All @@ -59,6 +59,7 @@ def format_output(result):
parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain")
parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help=f"Stack-specific search. Available: {', '.join(AVAILABLE_STACKS)}")
parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)")
parser.add_argument("--max-length", "-l", type=int, default=300, help="Max characters per field value (default: 300, 0 = unlimited)")
parser.add_argument("--json", action="store_true", help="Output as JSON")
# Design system generation
parser.add_argument("--design-system", "-ds", action="store_true", help="Generate complete design system recommendation")
Expand All @@ -73,14 +74,18 @@ def format_output(result):

# Design system takes priority
if args.design_system:
result = generate_design_system(
args.query,
args.project_name,
args.format,
persist=args.persist,
page=args.page,
output_dir=args.output_dir
)
try:
result = generate_design_system(
args.query,
args.project_name,
args.format,
persist=args.persist,
page=args.page,
output_dir=args.output_dir
)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(result)

# Print persistence confirmation
Expand All @@ -103,12 +108,12 @@ def format_output(result):
import json
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(format_output(result))
print(format_output(result, args.max_length))
# Domain search
else:
result = search(args.query, args.domain, args.max_results)
if args.json:
import json
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(format_output(result))
print(format_output(result, args.max_length))
Loading