diff --git a/.gitignore b/.gitignore index 9dbb1f45..51440fec 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,9 @@ npm-debug.log* # Environment .env -.env.local -.env.*.local +**/.env +**/.env.local +**/.env.*.local # Package manager yarn.lock diff --git a/packages/api/src/middleware/rate-limit.ts b/packages/api/src/middleware/rate-limit.ts index abc1039d..0144ec8d 100644 --- a/packages/api/src/middleware/rate-limit.ts +++ b/packages/api/src/middleware/rate-limit.ts @@ -1,4 +1,4 @@ -import type { Context, Next } from 'hono'; +import type { Context, Next } from "hono"; interface RateLimitEntry { count: number; @@ -18,8 +18,12 @@ export function rateLimiter(maxRequests = 60, windowMs = 60_000) { }, windowMs).unref(); return async (c: Context, next: Next): Promise => { - const forwardedFor = c.req.header('x-forwarded-for'); - const ip = (forwardedFor?.split(',')[0]?.trim()) || c.req.header('x-real-ip') || 'unknown'; + const xRealIp = c.req.header("x-real-ip"); + const forwarded = c.req.header("x-forwarded-for"); + const forwardedIp = forwarded + ? forwarded.split(",").at(-1)?.trim() + : undefined; + const ip = xRealIp || forwardedIp || "unknown"; const now = Date.now(); let entry = windows.get(ip); @@ -30,12 +34,21 @@ export function rateLimiter(maxRequests = 60, windowMs = 60_000) { entry.count++; - c.header('X-RateLimit-Limit', String(maxRequests)); - c.header('X-RateLimit-Remaining', String(Math.max(0, maxRequests - entry.count))); - c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))); + c.header("X-RateLimit-Limit", String(maxRequests)); + c.header( + "X-RateLimit-Remaining", + String(Math.max(0, maxRequests - entry.count)), + ); + c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000))); if (entry.count > maxRequests) { - return c.json({ error: 'Too many requests', retryAfter: Math.ceil((entry.resetAt - now) / 1000) }, 429); + return c.json( + { + error: "Too many requests", + retryAfter: Math.ceil((entry.resetAt - now) / 1000), + }, + 429, + ); } await next(); diff --git a/packages/api/src/routes/save.ts b/packages/api/src/routes/save.ts index 4612aa3f..01614327 100644 --- a/packages/api/src/routes/save.ts +++ b/packages/api/src/routes/save.ts @@ -1,5 +1,5 @@ -import { Hono } from 'hono'; -import { ContentExtractor, SkillGenerator, AutoTagger } from '@skillkit/core'; +import { Hono } from "hono"; +import { ContentExtractor, SkillGenerator, AutoTagger } from "@skillkit/core"; interface SaveRequest { url?: string; @@ -10,25 +10,40 @@ interface SaveRequest { const MAX_TEXT_LENGTH = 500_000; -const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1', '0.0.0.0']); +const BLOCKED_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "[::1]", + "::1", + "0.0.0.0", +]); function isAllowedUrl(url: string): boolean { try { const parsed = new URL(url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") + return false; const hostname = parsed.hostname.toLowerCase(); - const bare = hostname.replace(/^\[|\]$/g, ''); + const bare = hostname.replace(/^\[|\]$/g, ""); if (BLOCKED_HOSTS.has(hostname) || BLOCKED_HOSTS.has(bare)) return false; - if (bare.startsWith('::ffff:')) return isAllowedUrl(`http://${bare.slice(7)}`); + if (bare.startsWith("::ffff:")) + return isAllowedUrl(`http://${bare.slice(7)}`); if (/^127\./.test(bare) || /^0\./.test(bare)) return false; - if (bare.startsWith('10.') || bare.startsWith('192.168.')) return false; + if (bare.startsWith("10.") || bare.startsWith("192.168.")) return false; if (/^172\.(1[6-9]|2\d|3[01])\./.test(bare)) return false; - if (bare.startsWith('169.254.')) return false; - if (bare.startsWith('fe80:') || bare.startsWith('fc') || bare.startsWith('fd')) return false; + if (bare.startsWith("169.254.")) return false; + if (bare.includes(":")) { + if ( + bare.startsWith("fe80:") || + bare.startsWith("fc") || + bare.startsWith("fd") + ) + return false; + if (bare.startsWith("ff")) return false; + } if (/^(22[4-9]|23\d|24\d|25[0-5])\./.test(bare)) return false; - if (bare.startsWith('ff')) return false; return true; } catch { return false; @@ -41,12 +56,12 @@ export function saveRoutes() { const generator = new SkillGenerator(); const tagger = new AutoTagger(); - app.post('/save', async (c) => { + app.post("/save", async (c) => { let body: SaveRequest; try { body = await c.req.json(); } catch { - return c.json({ error: 'Invalid JSON body' }, 400); + return c.json({ error: "Invalid JSON body" }, 400); } if (!body.url && !body.text) { @@ -54,11 +69,26 @@ export function saveRoutes() { } if (body.url && !isAllowedUrl(body.url)) { - return c.json({ error: 'URL must be a public HTTP(S) address' }, 400); + return c.json({ error: "URL must be a public HTTP(S) address" }, 400); + } + + if (body.name && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(body.name)) { + return c.json( + { + error: + "Name must be alphanumeric (hyphens, underscores, dots allowed)", + }, + 400, + ); } if (body.text && body.text.length > MAX_TEXT_LENGTH) { - return c.json({ error: `Text exceeds maximum length of ${MAX_TEXT_LENGTH} characters` }, 400); + return c.json( + { + error: `Text exceeds maximum length of ${MAX_TEXT_LENGTH} characters`, + }, + 400, + ); } try { @@ -78,16 +108,17 @@ export function saveRoutes() { tags: tagger.detectTags(content), }); } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; + console.error("Save extraction failed:", err); const isTimeout = - (err instanceof DOMException && err.name === 'TimeoutError') || - (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')); + (err instanceof DOMException && err.name === "TimeoutError") || + (err instanceof Error && + (err.name === "TimeoutError" || err.name === "AbortError")); if (isTimeout) { - return c.json({ error: `Fetch timeout: ${message}` }, 504); + return c.json({ error: "Fetch timed out" }, 504); } - return c.json({ error: `Extraction failed: ${message}` }, 422); + return c.json({ error: "Extraction failed" }, 422); } }); diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index d964e35e..1692f97b 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -1,15 +1,15 @@ -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { MemoryCache } from '@skillkit/core'; -import { rateLimiter } from './middleware/rate-limit.js'; -import { healthRoutes } from './routes/health.js'; -import { searchRoutes } from './routes/search.js'; -import { skillRoutes } from './routes/skills.js'; -import { trendingRoutes } from './routes/trending.js'; -import { categoryRoutes } from './routes/categories.js'; -import { docsRoutes } from './routes/docs.js'; -import { saveRoutes } from './routes/save.js'; -import type { ApiSkill, SearchResponse } from './types.js'; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { MemoryCache } from "@skillkit/core"; +import { rateLimiter } from "./middleware/rate-limit.js"; +import { healthRoutes } from "./routes/health.js"; +import { searchRoutes } from "./routes/search.js"; +import { skillRoutes } from "./routes/skills.js"; +import { trendingRoutes } from "./routes/trending.js"; +import { categoryRoutes } from "./routes/categories.js"; +import { docsRoutes } from "./routes/docs.js"; +import { saveRoutes } from "./routes/save.js"; +import type { ApiSkill, SearchResponse } from "./types.js"; export interface ServerOptions { port?: number; @@ -29,26 +29,36 @@ export function createApp(options: ServerOptions = {}) { const app = new Hono(); - app.use('*', cors({ origin: options.corsOrigin || '*' })); - app.use('*', rateLimiter(options.rateLimitMax ?? 60)); + app.use("*", cors({ origin: options.corsOrigin || "*" })); + app.use("*", async (c, next) => { + await next(); + c.header("X-Content-Type-Options", "nosniff"); + c.header("X-Frame-Options", "DENY"); + c.header( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'", + ); + c.header("Referrer-Policy", "strict-origin-when-cross-origin"); + }); + app.use("*", rateLimiter(options.rateLimitMax ?? 60)); - app.route('/', healthRoutes(skills.length, cache)); - app.route('/', searchRoutes(skills, cache)); - app.route('/', skillRoutes(skills)); - app.route('/', trendingRoutes(skills)); - app.route('/', categoryRoutes(skills)); - app.route('/', docsRoutes()); - app.route('/', saveRoutes()); + app.route("/", healthRoutes(skills.length, cache)); + app.route("/", searchRoutes(skills, cache)); + app.route("/", skillRoutes(skills)); + app.route("/", trendingRoutes(skills)); + app.route("/", categoryRoutes(skills)); + app.route("/", docsRoutes()); + app.route("/", saveRoutes()); return { app, cache }; } export async function startServer(options: ServerOptions = {}) { const port = options.port ?? 3737; - const host = options.host ?? '0.0.0.0'; + const host = options.host ?? "127.0.0.1"; const { app, cache } = createApp(options); - const { serve } = await import('@hono/node-server'); + const { serve } = await import("@hono/node-server"); const server = serve({ fetch: app.fetch, port, hostname: host }, () => { console.log(`SkillKit API server running at http://${host}:${port}`); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 92de021d..fc528afd 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,13 +1,31 @@ -import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs'; -import { join, basename, dirname, resolve } from 'node:path'; -import chalk from 'chalk'; -import { Command, Option } from 'clipanion'; -import { generateWellKnownIndex, type WellKnownSkill, SkillScanner, formatSummary, Severity } from '@skillkit/core'; +import { + existsSync, + readFileSync, + mkdirSync, + writeFileSync, + readdirSync, + statSync, +} from "node:fs"; +import { join, basename, dirname, resolve, sep } from "node:path"; +import chalk from "chalk"; +import { Command, Option } from "clipanion"; +import { + generateWellKnownIndex, + type WellKnownSkill, + SkillScanner, + formatSummary, + Severity, +} from "@skillkit/core"; function sanitizeSkillName(name: string): string | null { - if (!name || typeof name !== 'string') return null; + if (!name || typeof name !== "string") return null; const base = basename(name); - if (base !== name || name.includes('..') || name.includes('/') || name.includes('\\')) { + if ( + base !== name || + name.includes("..") || + name.includes("/") || + name.includes("\\") + ) { return null; } if (!/^[a-zA-Z0-9._-]+$/.test(name)) { @@ -23,11 +41,64 @@ interface SkillFrontmatter { version?: string; } +function parseSkillFrontmatter(content: string): SkillFrontmatter { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + + const frontmatter: SkillFrontmatter = {}; + const lines = match[1].split(/\r?\n/); + let inTagsList = false; + + for (const line of lines) { + if (inTagsList) { + const tagMatch = line.match(/^\s*-\s*(.+)$/); + if (tagMatch) { + frontmatter.tags ??= []; + frontmatter.tags.push(tagMatch[1].trim().replace(/^["']|["']$/g, "")); + continue; + } + if (line.trim() === "") continue; + inTagsList = false; + } + + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + + switch (key) { + case "name": + frontmatter.name = value.replace(/^["']|["']$/g, ""); + break; + case "description": + frontmatter.description = value.replace(/^["']|["']$/g, ""); + break; + case "version": + frontmatter.version = value.replace(/^["']|["']$/g, ""); + break; + case "tags": + if (value.startsWith("[")) { + frontmatter.tags = value + .slice(1, -1) + .split(",") + .map((t) => t.trim().replace(/^["']|["']$/g, "")) + .filter((t) => t.length > 0); + } else if (value === "") { + inTagsList = true; + frontmatter.tags = []; + } + break; + } + } + return frontmatter; +} + export class PublishCommand extends Command { - static override paths = [['publish']]; + static override paths = [["publish"]]; static override usage = Command.Usage({ - description: 'Generate well-known skills structure for hosting', + description: "Generate well-known skills structure for hosting", details: ` This command generates the RFC 8615 well-known URI structure for hosting skills. @@ -38,59 +109,79 @@ export class PublishCommand extends Command { Users can then install skills via: skillkit add https://your-domain.com `, examples: [ - ['Generate from current directory', '$0 publish'], - ['Generate from specific path', '$0 publish ./my-skills'], - ['Generate to custom output directory', '$0 publish --output ./public'], - ['Preview without writing', '$0 publish --dry-run'], + ["Generate from current directory", "$0 publish"], + ["Generate from specific path", "$0 publish ./my-skills"], + ["Generate to custom output directory", "$0 publish --output ./public"], + ["Preview without writing", "$0 publish --dry-run"], ], }); - skillPath = Option.String({ required: false, name: 'path' }); + skillPath = Option.String({ required: false, name: "path" }); - output = Option.String('--output,-o', { - description: 'Output directory for well-known structure (default: current directory)', + output = Option.String("--output,-o", { + description: + "Output directory for well-known structure (default: current directory)", }); - dryRun = Option.Boolean('--dry-run,-n', false, { - description: 'Show what would be generated without writing files', + dryRun = Option.Boolean("--dry-run,-n", false, { + description: "Show what would be generated without writing files", }); - format = Option.String('--format', { - description: 'Output format: "standard" (default) or "mintlify" (.well-known/skills/default/skill.md)', + format = Option.String("--format", { + description: + 'Output format: "standard" (default) or "mintlify" (.well-known/skills/default/skill.md)', }); async execute(): Promise { const basePath = this.skillPath || process.cwd(); const outputDir = this.output || basePath; - console.log(chalk.cyan('Generating well-known skills structure...\n')); + console.log(chalk.cyan("Generating well-known skills structure...\n")); const discoveredSkills = this.discoverSkills(basePath); if (discoveredSkills.length === 0) { - console.error(chalk.red('No skills found')); - console.error(chalk.dim('Skills must contain a SKILL.md file with frontmatter')); + console.error(chalk.red("No skills found")); + console.error( + chalk.dim("Skills must contain a SKILL.md file with frontmatter"), + ); return 1; } console.log(chalk.white(`Found ${discoveredSkills.length} skill(s):\n`)); const wellKnownSkills: WellKnownSkill[] = []; - const validSkills: Array<{ name: string; safeName: string; description?: string; path: string }> = []; + const validSkills: Array<{ + name: string; + safeName: string; + description?: string; + path: string; + }> = []; for (const skill of discoveredSkills) { const safeName = sanitizeSkillName(skill.name); if (!safeName) { - console.log(chalk.yellow(` ${chalk.yellow('⚠')} Skipping "${skill.name}" (invalid name - must be alphanumeric with hyphens/underscores)`)); + console.log( + chalk.yellow( + ` ${chalk.yellow("⚠")} Skipping "${skill.name}" (invalid name - must be alphanumeric with hyphens/underscores)`, + ), + ); continue; } const files = this.getSkillFiles(skill.path); - console.log(chalk.dim(` ${chalk.green('●')} ${safeName}`)); - console.log(chalk.dim(` Description: ${skill.description || 'No description'}`)); - console.log(chalk.dim(` Files: ${files.join(', ')}`)); - - validSkills.push({ name: skill.name, safeName, description: skill.description, path: skill.path }); + console.log(chalk.dim(` ${chalk.green("●")} ${safeName}`)); + console.log( + chalk.dim(` Description: ${skill.description || "No description"}`), + ); + console.log(chalk.dim(` Files: ${files.join(", ")}`)); + + validSkills.push({ + name: skill.name, + safeName, + description: skill.description, + path: skill.path, + }); wellKnownSkills.push({ name: safeName, description: skill.description, @@ -101,78 +192,115 @@ export class PublishCommand extends Command { const scanner = new SkillScanner({ failOnSeverity: Severity.HIGH }); for (const skill of validSkills) { const scanResult = await scanner.scan(skill.path); - if (scanResult.verdict === 'fail') { - console.error(chalk.red(`\nSecurity scan FAILED for "${skill.safeName}"`)); + if (scanResult.verdict === "fail") { + console.error( + chalk.red(`\nSecurity scan FAILED for "${skill.safeName}"`), + ); console.error(formatSummary(scanResult)); - console.error(chalk.dim('Fix security issues before publishing.')); + console.error(chalk.dim("Fix security issues before publishing.")); return 1; } - if (scanResult.verdict === 'warn') { - console.log(chalk.yellow(` Security warnings for "${skill.safeName}" (${scanResult.findings.length} findings)`)); + if (scanResult.verdict === "warn") { + console.log( + chalk.yellow( + ` Security warnings for "${skill.safeName}" (${scanResult.findings.length} findings)`, + ), + ); } } if (validSkills.length === 0) { - console.error(chalk.red('\nNo valid skills to publish')); + console.error(chalk.red("\nNo valid skills to publish")); return 1; } - console.log(''); + console.log(""); - if (this.format === 'mintlify') { + if (this.format === "mintlify") { if (this.dryRun) { - console.log(chalk.yellow('Dry run - not writing files\n')); - console.log(chalk.white('Would generate (Mintlify format):')); + console.log(chalk.yellow("Dry run - not writing files\n")); + console.log(chalk.white("Would generate (Mintlify format):")); for (const skill of validSkills) { - console.log(chalk.dim(` ${outputDir}/.well-known/skills/${skill.safeName}/skill.md`)); + console.log( + chalk.dim( + ` ${outputDir}/.well-known/skills/${skill.safeName}/skill.md`, + ), + ); } return 0; } const resolvedOutput = resolve(outputDir); for (const skill of validSkills) { - const mintlifyDir = join(outputDir, '.well-known', 'skills', skill.safeName); + const mintlifyDir = join( + outputDir, + ".well-known", + "skills", + skill.safeName, + ); const resolvedDir = resolve(mintlifyDir); - if (!resolvedDir.startsWith(resolvedOutput)) { - console.log(chalk.red(`Skipping ${skill.safeName} (path traversal detected)`)); + if (!resolvedDir.startsWith(resolvedOutput + sep)) { + console.log( + chalk.red(`Skipping ${skill.safeName} (path traversal detected)`), + ); continue; } mkdirSync(mintlifyDir, { recursive: true }); - const skillMdPath = join(skill.path, 'SKILL.md'); + const skillMdPath = join(skill.path, "SKILL.md"); if (existsSync(skillMdPath)) { - const content = readFileSync(skillMdPath, 'utf-8'); - writeFileSync(join(mintlifyDir, 'skill.md'), content); + const content = readFileSync(skillMdPath, "utf-8"); + writeFileSync(join(mintlifyDir, "skill.md"), content); } } - console.log(chalk.green('Generated Mintlify well-known structure:\n')); + console.log(chalk.green("Generated Mintlify well-known structure:\n")); for (const skill of validSkills) { - console.log(chalk.dim(` ${outputDir}/.well-known/skills/${skill.safeName}/skill.md`)); + console.log( + chalk.dim( + ` ${outputDir}/.well-known/skills/${skill.safeName}/skill.md`, + ), + ); } - console.log(''); - console.log(chalk.cyan('Next steps:')); - console.log(chalk.dim(' 1. Deploy the .well-known directory to your web server')); - console.log(chalk.dim(' 2. Users can install via: skillkit install https://your-domain.com')); - console.log(chalk.dim(' 3. Skills auto-discovered from /.well-known/skills/{name}/skill.md')); + console.log(""); + console.log(chalk.cyan("Next steps:")); + console.log( + chalk.dim(" 1. Deploy the .well-known directory to your web server"), + ); + console.log( + chalk.dim( + " 2. Users can install via: skillkit install https://your-domain.com", + ), + ); + console.log( + chalk.dim( + " 3. Skills auto-discovered from /.well-known/skills/{name}/skill.md", + ), + ); return 0; } if (this.dryRun) { - console.log(chalk.yellow('Dry run - not writing files\n')); - console.log(chalk.white('Would generate:')); + console.log(chalk.yellow("Dry run - not writing files\n")); + console.log(chalk.white("Would generate:")); console.log(chalk.dim(` ${outputDir}/.well-known/skills/index.json`)); for (const skill of wellKnownSkills) { for (const file of skill.files) { - console.log(chalk.dim(` ${outputDir}/.well-known/skills/${skill.name}/${file}`)); + console.log( + chalk.dim( + ` ${outputDir}/.well-known/skills/${skill.name}/${file}`, + ), + ); } } - console.log(''); - console.log(chalk.white('index.json preview:')); - console.log(JSON.stringify(generateWellKnownIndex(wellKnownSkills), null, 2)); + console.log(""); + console.log(chalk.white("index.json preview:")); + console.log( + JSON.stringify(generateWellKnownIndex(wellKnownSkills), null, 2), + ); return 0; } - const wellKnownDir = join(outputDir, '.well-known', 'skills'); + const wellKnownDir = join(outputDir, ".well-known", "skills"); mkdirSync(wellKnownDir, { recursive: true }); for (const skill of validSkills) { @@ -180,8 +308,10 @@ export class PublishCommand extends Command { const resolvedSkillDir = resolve(skillDir); const resolvedWellKnownDir = resolve(wellKnownDir); - if (!resolvedSkillDir.startsWith(resolvedWellKnownDir)) { - console.log(chalk.yellow(` Skipping "${skill.name}" (path traversal detected)`)); + if (!resolvedSkillDir.startsWith(resolvedWellKnownDir + sep)) { + console.log( + chalk.yellow(` Skipping "${skill.name}" (path traversal detected)`), + ); continue; } @@ -192,35 +322,51 @@ export class PublishCommand extends Command { const safeFile = basename(file); const sourcePath = join(skill.path, file); const destPath = join(skillDir, safeFile); - const content = readFileSync(sourcePath, 'utf-8'); + const content = readFileSync(sourcePath, "utf-8"); writeFileSync(destPath, content); } } const index = generateWellKnownIndex(wellKnownSkills); - writeFileSync(join(wellKnownDir, 'index.json'), JSON.stringify(index, null, 2)); + writeFileSync( + join(wellKnownDir, "index.json"), + JSON.stringify(index, null, 2), + ); - console.log(chalk.green('Generated well-known structure:\n')); + console.log(chalk.green("Generated well-known structure:\n")); console.log(chalk.dim(` ${wellKnownDir}/index.json`)); for (const skill of wellKnownSkills) { console.log(chalk.dim(` ${wellKnownDir}/${skill.name}/`)); } - console.log(''); - console.log(chalk.cyan('Next steps:')); - console.log(chalk.dim(' 1. Deploy the .well-known directory to your web server')); - console.log(chalk.dim(' 2. Users can install via: skillkit add https://your-domain.com')); - console.log(chalk.dim(' 3. Skills auto-discovered from /.well-known/skills/index.json')); + console.log(""); + console.log(chalk.cyan("Next steps:")); + console.log( + chalk.dim(" 1. Deploy the .well-known directory to your web server"), + ); + console.log( + chalk.dim( + " 2. Users can install via: skillkit add https://your-domain.com", + ), + ); + console.log( + chalk.dim( + " 3. Skills auto-discovered from /.well-known/skills/index.json", + ), + ); return 0; } - private discoverSkills(basePath: string): Array<{ name: string; description?: string; path: string }> { - const skills: Array<{ name: string; description?: string; path: string }> = []; + private discoverSkills( + basePath: string, + ): Array<{ name: string; description?: string; path: string }> { + const skills: Array<{ name: string; description?: string; path: string }> = + []; - const skillMdPath = join(basePath, 'SKILL.md'); + const skillMdPath = join(basePath, "SKILL.md"); if (existsSync(skillMdPath)) { - const content = readFileSync(skillMdPath, 'utf-8'); + const content = readFileSync(skillMdPath, "utf-8"); const frontmatter = this.parseFrontmatter(content); skills.push({ name: frontmatter.name || basename(basePath), @@ -232,8 +378,8 @@ export class PublishCommand extends Command { const searchDirs = [ basePath, - join(basePath, 'skills'), - join(basePath, '.claude', 'skills'), + join(basePath, "skills"), + join(basePath, ".claude", "skills"), ]; for (const searchDir of searchDirs) { @@ -244,9 +390,9 @@ export class PublishCommand extends Command { const entryPath = join(searchDir, entry); if (!statSync(entryPath).isDirectory()) continue; - const entrySkillMd = join(entryPath, 'SKILL.md'); + const entrySkillMd = join(entryPath, "SKILL.md"); if (existsSync(entrySkillMd)) { - const content = readFileSync(entrySkillMd, 'utf-8'); + const content = readFileSync(entrySkillMd, "utf-8"); const frontmatter = this.parseFrontmatter(content); skills.push({ name: frontmatter.name || entry, @@ -267,92 +413,42 @@ export class PublishCommand extends Command { for (const entry of entries) { const entryPath = join(skillPath, entry); if (statSync(entryPath).isFile()) { - if (entry.startsWith('.') || entry === '.skillkit-metadata.json') continue; + if (entry.startsWith(".")) continue; files.push(entry); } } - if (!files.includes('SKILL.md')) { - files.unshift('SKILL.md'); + if (!files.includes("SKILL.md")) { + files.unshift("SKILL.md"); } return files; } private parseFrontmatter(content: string): SkillFrontmatter { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return {}; - - const frontmatter: SkillFrontmatter = {}; - const lines = match[1].split(/\r?\n/); - let inTagsList = false; - - for (const line of lines) { - if (inTagsList) { - const tagMatch = line.match(/^\s*-\s*(.+)$/); - if (tagMatch) { - frontmatter.tags ??= []; - frontmatter.tags.push(tagMatch[1].trim().replace(/^["']|["']$/g, '')); - continue; - } - if (line.trim() === '') continue; - inTagsList = false; - } - - const colonIdx = line.indexOf(':'); - if (colonIdx === -1) continue; - - const key = line.slice(0, colonIdx).trim(); - const value = line.slice(colonIdx + 1).trim(); - - switch (key) { - case 'name': - frontmatter.name = value.replace(/^["']|["']$/g, ''); - break; - case 'description': - frontmatter.description = value.replace(/^["']|["']$/g, ''); - break; - case 'version': - frontmatter.version = value.replace(/^["']|["']$/g, ''); - break; - case 'tags': - if (value.startsWith('[')) { - frontmatter.tags = value - .slice(1, -1) - .split(',') - .map(t => t.trim().replace(/^["']|["']$/g, '')) - .filter(t => t.length > 0); - } else if (value === '') { - inTagsList = true; - frontmatter.tags = []; - } - break; - } - } - - return frontmatter; + return parseSkillFrontmatter(content); } } export class PublishSubmitCommand extends Command { - static override paths = [['publish', 'submit']]; + static override paths = [["publish", "submit"]]; static override usage = Command.Usage({ - description: 'Submit skill to SkillKit marketplace (requires review)', + description: "Submit skill to SkillKit marketplace (requires review)", examples: [ - ['Submit skill from current directory', '$0 publish submit'], - ['Submit with custom name', '$0 publish submit --name my-skill'], + ["Submit skill from current directory", "$0 publish submit"], + ["Submit with custom name", "$0 publish submit --name my-skill"], ], }); - skillPath = Option.String({ required: false, name: 'path' }); + skillPath = Option.String({ required: false, name: "path" }); - name = Option.String('--name,-n', { - description: 'Custom skill name', + name = Option.String("--name,-n", { + description: "Custom skill name", }); - dryRun = Option.Boolean('--dry-run', false, { - description: 'Show what would be submitted', + dryRun = Option.Boolean("--dry-run", false, { + description: "Show what would be submitted", }); async execute(): Promise { @@ -360,49 +456,58 @@ export class PublishSubmitCommand extends Command { const skillMdPath = this.findSkillMd(skillPath); if (!skillMdPath) { - console.error(chalk.red('No SKILL.md found')); - console.error(chalk.dim('Run this command from a directory containing SKILL.md')); + console.error(chalk.red("No SKILL.md found")); + console.error( + chalk.dim("Run this command from a directory containing SKILL.md"), + ); return 1; } - console.log(chalk.cyan('Submitting skill to SkillKit marketplace...\n')); + console.log(chalk.cyan("Submitting skill to SkillKit marketplace...\n")); - const content = readFileSync(skillMdPath, 'utf-8'); + const content = readFileSync(skillMdPath, "utf-8"); const frontmatter = this.parseFrontmatter(content); - const skillName = this.name || frontmatter.name || basename(dirname(skillMdPath)); + const skillName = + this.name || frontmatter.name || basename(dirname(skillMdPath)); - const repoInfo = this.getRepoInfo(dirname(skillMdPath)); + const repoInfo = await this.getRepoInfo(dirname(skillMdPath)); if (!repoInfo) { - console.error(chalk.red('Not a git repository or no remote configured')); - console.error(chalk.dim('Your skill must be in a git repository with a GitHub remote')); + console.error(chalk.red("Not a git repository or no remote configured")); + console.error( + chalk.dim( + "Your skill must be in a git repository with a GitHub remote", + ), + ); return 1; } const skillSlug = this.slugify(skillName); if (!skillSlug) { - console.error(chalk.red('Skill name produces an empty slug.')); - console.error(chalk.dim('Please pass --name with letters or numbers.')); + console.error(chalk.red("Skill name produces an empty slug.")); + console.error(chalk.dim("Please pass --name with letters or numbers.")); return 1; } const skillEntry = { id: `${repoInfo.owner}/${repoInfo.repo}/${skillSlug}`, name: this.formatName(skillName), - description: frontmatter.description || `Best practices for ${this.formatName(skillName)}`, + description: + frontmatter.description || + `Best practices for ${this.formatName(skillName)}`, source: `${repoInfo.owner}/${repoInfo.repo}`, - tags: frontmatter.tags || ['general'], + tags: frontmatter.tags || ["general"], }; - console.log(chalk.white('Skill details:')); + console.log(chalk.white("Skill details:")); console.log(chalk.dim(` ID: ${skillEntry.id}`)); console.log(chalk.dim(` Name: ${skillEntry.name}`)); console.log(chalk.dim(` Description: ${skillEntry.description}`)); console.log(chalk.dim(` Source: ${skillEntry.source}`)); - console.log(chalk.dim(` Tags: ${skillEntry.tags.join(', ')}`)); + console.log(chalk.dim(` Tags: ${skillEntry.tags.join(", ")}`)); console.log(); if (this.dryRun) { - console.log(chalk.yellow('Dry run - not submitting')); + console.log(chalk.yellow("Dry run - not submitting")); console.log(JSON.stringify(skillEntry, null, 2)); return 0; } @@ -412,23 +517,27 @@ export class PublishSubmitCommand extends Command { const issueBodyEncoded = encodeURIComponent(issueBody); const issueUrl = `https://github.com/rohitg00/skillkit/issues/new?title=${issueTitle}&body=${issueBodyEncoded}&labels=skill-submission,publish`; - console.log(chalk.green('Opening GitHub to submit your skill...\n')); + console.log(chalk.green("Opening GitHub to submit your skill...\n")); try { - const { execSync } = await import('node:child_process'); - const openCmd = - process.platform === 'darwin' - ? `open "${issueUrl}"` - : process.platform === 'win32' - ? `cmd /c start "" "${issueUrl}"` - : `xdg-open "${issueUrl}"`; - execSync(openCmd, { stdio: 'ignore' }); - - console.log(chalk.green('GitHub issue page opened!')); - console.log(chalk.dim('Review and submit the issue.')); + const { execFileSync } = await import("node:child_process"); + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = + process.platform === "win32" + ? ["/c", "start", "", issueUrl] + : [issueUrl]; + execFileSync(cmd, args, { stdio: "ignore" }); + + console.log(chalk.green("GitHub issue page opened!")); + console.log(chalk.dim("Review and submit the issue.")); } catch { - console.log(chalk.yellow('Could not open browser automatically.')); - console.log(chalk.dim('Please open this URL manually:\n')); + console.log(chalk.yellow("Could not open browser automatically.")); + console.log(chalk.dim("Please open this URL manually:\n")); console.log(chalk.cyan(issueUrl)); } @@ -436,18 +545,18 @@ export class PublishSubmitCommand extends Command { } private findSkillMd(basePath: string): string | null { - if (basePath.endsWith('SKILL.md') && existsSync(basePath)) { + if (basePath.endsWith("SKILL.md") && existsSync(basePath)) { return basePath; } - const direct = join(basePath, 'SKILL.md'); + const direct = join(basePath, "SKILL.md"); if (existsSync(direct)) { return direct; } const locations = [ - join(basePath, 'skills', 'SKILL.md'), - join(basePath, '.claude', 'skills', 'SKILL.md'), + join(basePath, "skills", "SKILL.md"), + join(basePath, ".claude", "skills", "SKILL.md"), ]; for (const loc of locations) { @@ -460,59 +569,26 @@ export class PublishSubmitCommand extends Command { } private parseFrontmatter(content: string): SkillFrontmatter { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return {}; - - const frontmatter: SkillFrontmatter = {}; - const lines = match[1].split(/\r?\n/); - - for (const line of lines) { - const colonIdx = line.indexOf(':'); - if (colonIdx === -1) continue; - - const key = line.slice(0, colonIdx).trim(); - const value = line.slice(colonIdx + 1).trim(); - - switch (key) { - case 'name': - frontmatter.name = value.replace(/^["']|["']$/g, ''); - break; - case 'description': - frontmatter.description = value.replace(/^["']|["']$/g, ''); - break; - case 'version': - frontmatter.version = value.replace(/^["']|["']$/g, ''); - break; - case 'tags': - if (value.startsWith('[')) { - frontmatter.tags = value - .slice(1, -1) - .split(',') - .map(t => t.trim().replace(/^["']|["']$/g, '')) - .filter(t => t.length > 0); - } - break; - } - } - - return frontmatter; + return parseSkillFrontmatter(content); } private slugify(name: string): string { return name .toLowerCase() .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); } - private getRepoInfo(dir: string): { owner: string; repo: string } | null { + private async getRepoInfo( + dir: string, + ): Promise<{ owner: string; repo: string } | null> { try { - const { execSync } = require('node:child_process'); - const remote = execSync('git remote get-url origin', { + const { execFileSync } = await import("node:child_process"); + const remote = execFileSync("git", ["remote", "get-url", "origin"], { cwd: dir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'ignore'], + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], }).trim(); const match = remote.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/); @@ -529,13 +605,17 @@ export class PublishSubmitCommand extends Command { private formatName(name: string): string { return name .split(/[-_]/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); } - private createIssueBody( - skill: { id: string; name: string; description: string; source: string; tags: string[] } - ): string { + private createIssueBody(skill: { + id: string; + name: string; + description: string; + source: string; + tags: string[]; + }): string { return `## Publish Skill Request ### Skill Details @@ -543,7 +623,7 @@ export class PublishSubmitCommand extends Command { - **Name:** ${skill.name} - **Description:** ${skill.description} - **Source:** [${skill.source}](https://github.com/${skill.source}) -- **Tags:** ${skill.tags.map(t => `\`${t}\``).join(', ')} +- **Tags:** ${skill.tags.map((t) => `\`${t}\``).join(", ")} ### JSON Entry \`\`\`json diff --git a/packages/core/src/executor/engine.ts b/packages/core/src/executor/engine.ts index ebd3204b..19eb213b 100644 --- a/packages/core/src/executor/engine.ts +++ b/packages/core/src/executor/engine.ts @@ -4,8 +4,9 @@ * Executes skills with task-based orchestration, verification, and state management. */ -import { execSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { splitCommand } from "../utils/shell.js"; import type { ExecutableSkill, ExecutableTask, @@ -15,20 +16,25 @@ import type { CheckpointHandler, CheckpointResponse, ExecutionTaskStatus, -} from './types.js'; -import { SessionManager } from '../session/manager.js'; -import type { SessionTask } from '../session/types.js'; +} from "./types.js"; +import { SessionManager } from "../session/manager.js"; +import type { SessionTask } from "../session/types.js"; /** * Progress event for execution */ export interface ExecutionProgressEvent { - type: 'task_start' | 'task_complete' | 'checkpoint' | 'verification' | 'complete'; + type: + | "task_start" + | "task_complete" + | "checkpoint" + | "verification" + | "complete"; taskId?: string; taskName?: string; taskIndex?: number; totalTasks?: number; - status?: ExecutionTaskStatus | 'paused' | 'cancelled'; + status?: ExecutionTaskStatus | "paused" | "cancelled"; message?: string; error?: string; } @@ -52,7 +58,7 @@ export class SkillExecutionEngine { options?: { checkpointHandler?: CheckpointHandler; onProgress?: ExecutionProgressCallback; - } + }, ) { this.projectPath = projectPath; this.sessionManager = new SessionManager(projectPath); @@ -65,7 +71,7 @@ export class SkillExecutionEngine { */ async execute( skill: ExecutableSkill, - options: ExecutionOptions = {} + options: ExecutionOptions = {}, ): Promise { const startTime = new Date(); const tasks = skill.tasks || []; @@ -78,23 +84,26 @@ export class SkillExecutionEngine { // Check if there's an existing paused execution for this skill const existingState = this.sessionManager.get(); if (existingState?.currentExecution?.skillName === skill.name) { - if (existingState.currentExecution.status === 'paused') { + if (existingState.currentExecution.status === "paused") { // Resume from paused state return this.resumeExecution(skill, options); } } // Start new execution - const sessionTasks: Omit[] = tasks.map((task, index) => ({ - id: task.id || `task-${index}`, - name: task.name, - type: task.type === 'auto' ? 'auto' : task.type, - })); + const sessionTasks: Omit[] = tasks.map( + (task, index) => ({ + id: task.id || `task-${index}`, + name: task.name, + type: task.type === "auto" ? "auto" : task.type, + }), + ); this.sessionManager.startExecution(skill.name, skill.source, sessionTasks); const taskResults: TaskExecutionResult[] = []; - let overallStatus: 'completed' | 'failed' | 'cancelled' | 'paused' = 'completed'; + let overallStatus: "completed" | "failed" | "cancelled" | "paused" = + "completed"; let overallError: string | undefined; // Execute tasks @@ -102,7 +111,7 @@ export class SkillExecutionEngine { const task = tasks[i]; this.onProgress?.({ - type: 'task_start', + type: "task_start", taskId: task.id, taskName: task.name, taskIndex: i, @@ -110,7 +119,7 @@ export class SkillExecutionEngine { }); // Handle checkpoints - if (task.type !== 'auto') { + if (task.type !== "auto") { const checkpointResult = await this.handleCheckpoint(task, { skillName: skill.name, taskIndex: i, @@ -118,17 +127,20 @@ export class SkillExecutionEngine { }); if (!checkpointResult.continue) { - overallStatus = 'paused'; + overallStatus = "paused"; this.sessionManager.pause(); break; } // For decision checkpoints, record the decision - if (task.type === 'checkpoint:decision' && checkpointResult.selectedOption) { + if ( + task.type === "checkpoint:decision" && + checkpointResult.selectedOption + ) { this.sessionManager.recordDecision( `${skill.name}:${task.id}`, checkpointResult.selectedOption, - skill.name + skill.name, ); } } @@ -138,7 +150,7 @@ export class SkillExecutionEngine { taskResults.push(taskResult); this.sessionManager.updateTask(task.id || `task-${i}`, { - status: taskResult.status === 'completed' ? 'completed' : 'failed', + status: taskResult.status === "completed" ? "completed" : "failed", output: taskResult.output, error: taskResult.error, filesModified: taskResult.filesModified, @@ -146,7 +158,7 @@ export class SkillExecutionEngine { }); this.onProgress?.({ - type: 'task_complete', + type: "task_complete", taskId: task.id, taskName: task.name, taskIndex: i, @@ -156,9 +168,9 @@ export class SkillExecutionEngine { }); // Handle task failure - if (taskResult.status === 'failed') { + if (taskResult.status === "failed") { if (!options.continueOnError) { - overallStatus = 'failed'; + overallStatus = "failed"; overallError = taskResult.error; break; } @@ -168,8 +180,8 @@ export class SkillExecutionEngine { if (options.verify && task.verify) { const verificationPassed = await this.runVerification(task, taskResult); if (!verificationPassed && !options.continueOnError) { - overallStatus = 'failed'; - overallError = 'Verification failed'; + overallStatus = "failed"; + overallError = "Verification failed"; break; } } @@ -178,7 +190,7 @@ export class SkillExecutionEngine { const endTime = new Date(); // Complete the execution - if (overallStatus !== 'paused') { + if (overallStatus !== "paused") { this.sessionManager.completeExecution(overallStatus, overallError); } @@ -190,20 +202,27 @@ export class SkillExecutionEngine { completedAt: endTime.toISOString(), durationMs: endTime.getTime() - startTime.getTime(), tasks: taskResults, - filesModified: Array.from(new Set(taskResults.flatMap((t) => t.filesModified || []))), - commits: taskResults.map((t) => t.commitSha).filter((sha): sha is string => !!sha), + filesModified: Array.from( + new Set(taskResults.flatMap((t) => t.filesModified || [])), + ), + commits: taskResults + .map((t) => t.commitSha) + .filter((sha): sha is string => !!sha), error: overallError, }; - const statusMessages: Record<'completed' | 'failed' | 'cancelled' | 'paused', string> = { - completed: 'Skill execution completed', - paused: 'Skill execution paused', - failed: overallError || 'Skill execution failed', - cancelled: 'Skill execution cancelled', + const statusMessages: Record< + "completed" | "failed" | "cancelled" | "paused", + string + > = { + completed: "Skill execution completed", + paused: "Skill execution paused", + failed: overallError || "Skill execution failed", + cancelled: "Skill execution cancelled", }; this.onProgress?.({ - type: 'complete', + type: "complete", status: overallStatus, message: statusMessages[overallStatus], }); @@ -216,11 +235,11 @@ export class SkillExecutionEngine { */ private async resumeExecution( skill: ExecutableSkill, - options: ExecutionOptions + options: ExecutionOptions, ): Promise { const state = this.sessionManager.get(); if (!state?.currentExecution) { - throw new Error('No execution to resume'); + throw new Error("No execution to resume"); } this.sessionManager.resume(); @@ -228,7 +247,8 @@ export class SkillExecutionEngine { const tasks = skill.tasks || []; const taskResults: TaskExecutionResult[] = []; - let overallStatus: 'completed' | 'failed' | 'cancelled' | 'paused' = 'completed'; + let overallStatus: "completed" | "failed" | "cancelled" | "paused" = + "completed"; let overallError: string | undefined; // Find where to resume from @@ -238,7 +258,7 @@ export class SkillExecutionEngine { const task = tasks[i]; this.onProgress?.({ - type: 'task_start', + type: "task_start", taskId: task.id, taskName: task.name, taskIndex: i, @@ -246,7 +266,7 @@ export class SkillExecutionEngine { }); // Handle checkpoints (same as in execute) - if (task.type !== 'auto') { + if (task.type !== "auto") { const checkpointResult = await this.handleCheckpoint(task, { skillName: skill.name, taskIndex: i, @@ -254,17 +274,20 @@ export class SkillExecutionEngine { }); if (!checkpointResult.continue) { - overallStatus = 'paused'; + overallStatus = "paused"; this.sessionManager.pause(); break; } // For decision checkpoints, record the decision - if (task.type === 'checkpoint:decision' && checkpointResult.selectedOption) { + if ( + task.type === "checkpoint:decision" && + checkpointResult.selectedOption + ) { this.sessionManager.recordDecision( `${skill.name}:${task.id}`, checkpointResult.selectedOption, - skill.name + skill.name, ); } } @@ -273,7 +296,7 @@ export class SkillExecutionEngine { taskResults.push(taskResult); this.sessionManager.updateTask(task.id || `task-${i}`, { - status: taskResult.status === 'completed' ? 'completed' : 'failed', + status: taskResult.status === "completed" ? "completed" : "failed", output: taskResult.output, error: taskResult.error, filesModified: taskResult.filesModified, @@ -282,7 +305,7 @@ export class SkillExecutionEngine { // Emit task_complete progress event (matching execute behavior) this.onProgress?.({ - type: 'task_complete', + type: "task_complete", taskId: task.id, taskName: task.name, taskIndex: i, @@ -292,9 +315,9 @@ export class SkillExecutionEngine { }); // Handle task failure - if (taskResult.status === 'failed') { + if (taskResult.status === "failed") { if (!options.continueOnError) { - overallStatus = 'failed'; + overallStatus = "failed"; overallError = taskResult.error; break; } @@ -304,15 +327,15 @@ export class SkillExecutionEngine { if (options.verify && task.verify) { const verificationPassed = await this.runVerification(task, taskResult); if (!verificationPassed && !options.continueOnError) { - overallStatus = 'failed'; - overallError = 'Verification failed'; + overallStatus = "failed"; + overallError = "Verification failed"; break; } } } // Only complete execution if not paused - if (overallStatus !== 'paused') { + if (overallStatus !== "paused") { this.sessionManager.completeExecution(overallStatus, overallError); } @@ -324,8 +347,12 @@ export class SkillExecutionEngine { completedAt: new Date().toISOString(), durationMs: Date.now() - new Date(execution.startedAt).getTime(), tasks: taskResults, - filesModified: Array.from(new Set(taskResults.flatMap((t) => t.filesModified || []))), - commits: taskResults.map((t) => t.commitSha).filter((sha): sha is string => !!sha), + filesModified: Array.from( + new Set(taskResults.flatMap((t) => t.filesModified || [])), + ), + commits: taskResults + .map((t) => t.commitSha) + .filter((sha): sha is string => !!sha), error: overallError, }; } @@ -336,7 +363,7 @@ export class SkillExecutionEngine { private async executeTask( task: ExecutableTask, _skill: ExecutableSkill, - _options: ExecutionOptions + _options: ExecutionOptions, ): Promise { const startTime = new Date(); const taskId = task.id || randomUUID(); @@ -354,7 +381,7 @@ export class SkillExecutionEngine { return { taskId, taskName: task.name, - status: 'completed', + status: "completed", startedAt: startTime.toISOString(), completedAt: endTime.toISOString(), durationMs: endTime.getTime() - startTime.getTime(), @@ -367,7 +394,7 @@ export class SkillExecutionEngine { return { taskId, taskName: task.name, - status: 'failed', + status: "failed", startedAt: startTime.toISOString(), completedAt: endTime.toISOString(), durationMs: endTime.getTime() - startTime.getTime(), @@ -381,10 +408,10 @@ export class SkillExecutionEngine { */ private async handleCheckpoint( task: ExecutableTask, - context: { skillName: string; taskIndex: number; totalTasks: number } + context: { skillName: string; taskIndex: number; totalTasks: number }, ): Promise { this.onProgress?.({ - type: 'checkpoint', + type: "checkpoint", taskId: task.id, taskName: task.name, taskIndex: context.taskIndex, @@ -397,7 +424,7 @@ export class SkillExecutionEngine { } // Default: auto-continue for non-decision checkpoints - if (task.type === 'checkpoint:decision') { + if (task.type === "checkpoint:decision") { // Use first option if no handler return { continue: true, @@ -447,7 +474,11 @@ export class SkillExecutionEngine { * This method validates patterns before execution to prevent catastrophic backtracking. * Note: This cannot guarantee protection against all ReDoS patterns, but catches common ones. */ - private safeRegexTest(pattern: string, input: string, _timeoutMs = 1000): boolean { + private safeRegexTest( + pattern: string, + input: string, + _timeoutMs = 1000, + ): boolean { // Strict length limits to prevent excessive processing if (pattern.length > 200 || input.length > 50000) { console.warn(`Regex test skipped: pattern or input too long`); @@ -456,7 +487,9 @@ export class SkillExecutionEngine { // Reject patterns known to cause ReDoS if (this.isUnsafeRegexPattern(pattern)) { - console.warn(`Regex pattern rejected as potentially unsafe: ${pattern.slice(0, 50)}...`); + console.warn( + `Regex pattern rejected as potentially unsafe: ${pattern.slice(0, 50)}...`, + ); return false; } @@ -478,7 +511,7 @@ export class SkillExecutionEngine { */ private async runVerification( task: ExecutableTask, - result: TaskExecutionResult + result: TaskExecutionResult, ): Promise { if (!task.verify) { return true; @@ -494,21 +527,23 @@ export class SkillExecutionEngine { for (const rule of task.verify.automated) { if (rule.command) { try { - // SECURITY: Commands are from skill config - only run trusted skills - const output = execSync(rule.command, { + const cmdParts = splitCommand(rule.command); + if (cmdParts.length === 0) return false; + const [cmd, ...sanitizedArgs] = cmdParts; + const output = execFileSync(cmd, sanitizedArgs, { cwd: this.projectPath, - encoding: 'utf-8', + encoding: "utf-8", timeout: 30000, }); let passed = true; if (rule.expect) { - if (rule.expect === 'success') { + if (rule.expect === "success") { passed = true; - } else if (rule.expect.startsWith('contains:')) { + } else if (rule.expect.startsWith("contains:")) { const expected = rule.expect.slice(9); passed = output.includes(expected); - } else if (rule.expect.startsWith('matches:')) { + } else if (rule.expect.startsWith("matches:")) { const pattern = rule.expect.slice(8); // Use safe regex test to prevent ReDoS passed = this.safeRegexTest(pattern, output); @@ -522,10 +557,10 @@ export class SkillExecutionEngine { }); this.onProgress?.({ - type: 'verification', + type: "verification", taskId: task.id, taskName: task.name, - message: `Verification ${passed ? 'passed' : 'failed'}: ${rule.command}`, + message: `Verification ${passed ? "passed" : "failed"}: ${rule.command}`, }); if (!passed) { @@ -557,18 +592,18 @@ export class SkillExecutionEngine { return { skillName: skill.name, skillSource: skill.source, - status: 'completed', + status: "completed", startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), durationMs: 0, tasks: tasks.map((task) => ({ taskId: task.id || randomUUID(), taskName: task.name, - status: 'skipped' as ExecutionTaskStatus, + status: "skipped" as ExecutionTaskStatus, startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), durationMs: 0, - output: '[Dry run - not executed]', + output: "[Dry run - not executed]", })), filesModified: [], commits: [], @@ -605,7 +640,7 @@ export function createExecutionEngine( options?: { checkpointHandler?: CheckpointHandler; onProgress?: ExecutionProgressCallback; - } + }, ): SkillExecutionEngine { return new SkillExecutionEngine(projectPath, options); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 82d6c6bb..5b9c3d8a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -132,3 +132,6 @@ export * from './agents-md/index.js'; // Save (Content Extraction & Skill Generation) export * from './save/index.js'; + +// Utilities +export { splitCommand } from './utils/shell.js'; diff --git a/packages/core/src/plan/executor.ts b/packages/core/src/plan/executor.ts index b9ab2959..84147e3f 100644 --- a/packages/core/src/plan/executor.ts +++ b/packages/core/src/plan/executor.ts @@ -13,7 +13,9 @@ import type { PlanExecutionResult, PlanEvent, PlanEventListener, -} from './types.js'; +} from "./types.js"; +import { execFileSync } from "node:child_process"; +import { splitCommand } from "../utils/shell.js"; /** * Step executor function type @@ -21,7 +23,7 @@ import type { export type StepExecutor = ( step: TaskStep, task: PlanTask, - plan: StructuredPlan + plan: StructuredPlan, ) => Promise<{ success: boolean; output: string; error?: string }>; /** @@ -49,7 +51,10 @@ export class PlanExecutor { /** * Execute a plan */ - async execute(plan: StructuredPlan, options?: PlanExecutionOptions): Promise { + async execute( + plan: StructuredPlan, + options?: PlanExecutionOptions, + ): Promise { const startTime = Date.now(); let aborted = false; const result: PlanExecutionResult = { @@ -66,8 +71,8 @@ export class PlanExecutor { this.isPaused = false; // Update plan status - plan.status = 'executing'; - this.emit('plan:execution_started', plan); + plan.status = "executing"; + this.emit("plan:execution_started", plan); try { // Build dependency graph @@ -82,7 +87,7 @@ export class PlanExecutor { if (this.abortController.signal.aborted) { aborted = true; result.success = false; - result.errors = [...(result.errors ?? []), 'Execution cancelled']; + result.errors = [...(result.errors ?? []), "Execution cancelled"]; break; } @@ -93,7 +98,7 @@ export class PlanExecutor { if (this.abortController.signal.aborted) { aborted = true; result.success = false; - result.errors = [...(result.errors ?? []), 'Execution cancelled']; + result.errors = [...(result.errors ?? []), "Execution cancelled"]; break; } @@ -103,7 +108,7 @@ export class PlanExecutor { // Check if dependencies completed const depsCompleted = this.checkDependencies(task, result); if (!depsCompleted) { - task.status = 'skipped'; + task.status = "skipped"; result.skippedTasks.push(taskId); continue; } @@ -113,23 +118,27 @@ export class PlanExecutor { result.taskResults.set(taskId, taskResult); if (taskResult.success) { - task.status = 'completed'; + task.status = "completed"; task.result = taskResult; result.completedTasks.push(taskId); - this.emit('plan:task_completed', plan, task, taskResult); + this.emit("plan:task_completed", plan, task, taskResult); } else { - task.status = 'failed'; + task.status = "failed"; task.result = taskResult; result.failedTasks.push(taskId); result.success = false; - this.emit('plan:task_failed', plan, task, taskResult); + this.emit("plan:task_failed", plan, task, taskResult); if (options?.stopOnError) { // Mark remaining tasks as skipped - for (const remainingId of executionOrder.slice(executionOrder.indexOf(taskId) + 1)) { - const remainingTask = plan.tasks.find((t) => t.id === remainingId); + for (const remainingId of executionOrder.slice( + executionOrder.indexOf(taskId) + 1, + )) { + const remainingTask = plan.tasks.find( + (t) => t.id === remainingId, + ); if (remainingTask) { - remainingTask.status = 'skipped'; + remainingTask.status = "skipped"; result.skippedTasks.push(remainingId); } } @@ -139,28 +148,32 @@ export class PlanExecutor { } // Update plan status - plan.status = aborted ? 'cancelled' : result.success ? 'completed' : 'failed'; + plan.status = aborted + ? "cancelled" + : result.success + ? "completed" + : "failed"; plan.updatedAt = new Date(); result.durationMs = Date.now() - startTime; // Emit appropriate event if (aborted) { - this.emit('plan:execution_cancelled', plan); + this.emit("plan:execution_cancelled", plan); } else if (result.success) { - this.emit('plan:execution_completed', plan); + this.emit("plan:execution_completed", plan); } else { - this.emit('plan:execution_failed', plan); + this.emit("plan:execution_failed", plan); } return result; } catch (error) { - plan.status = 'failed'; + plan.status = "failed"; result.success = false; result.durationMs = Date.now() - startTime; result.errors = [(error as Error).message]; - this.emit('plan:execution_failed', plan); + this.emit("plan:execution_failed", plan); return result; } finally { // Reset executor state @@ -177,18 +190,18 @@ export class PlanExecutor { private async executeTask( task: PlanTask, plan: StructuredPlan, - options?: PlanExecutionOptions + options?: PlanExecutionOptions, ): Promise { const startTime = Date.now(); const errors: string[] = []; const filesCreated: string[] = []; const filesModified: string[] = []; - task.status = 'in_progress'; - this.emit('plan:task_started', plan, task); + task.status = "in_progress"; + this.emit("plan:task_started", plan, task); // Report progress - options?.onProgress?.(task.id, 0, 'starting'); + options?.onProgress?.(task.id, 0, "starting"); // Execute steps for (let i = 0; i < task.steps.length; i++) { @@ -196,7 +209,7 @@ export class PlanExecutor { // Check if aborted if (this.abortController?.signal.aborted) { - errors.push('Execution aborted'); + errors.push("Execution aborted"); break; } @@ -205,17 +218,23 @@ export class PlanExecutor { // Re-check if aborted after resuming from pause if (this.abortController?.signal.aborted) { - errors.push('Execution aborted'); + errors.push("Execution aborted"); break; } // Report progress - options?.onProgress?.(task.id, step.number, `executing step ${step.number}`); + options?.onProgress?.( + task.id, + step.number, + `executing step ${step.number}`, + ); // Execute step if (options?.dryRun) { // Dry run - just log - console.log(`[DRY RUN] Task ${task.id}, Step ${step.number}: ${step.description}`); + console.log( + `[DRY RUN] Task ${task.id}, Step ${step.number}: ${step.description}`, + ); if (step.command) { console.log(` Command: ${step.command}`); } @@ -226,11 +245,11 @@ export class PlanExecutor { try { const stepResult = await this.executeWithTimeout( this.stepExecutor(step, task, plan), - options?.taskTimeout || 60000 + options?.taskTimeout || 60000, ); if (!stepResult.success) { - errors.push(`Step ${step.number}: ${stepResult.error || 'Failed'}`); + errors.push(`Step ${step.number}: ${stepResult.error || "Failed"}`); if (step.critical) { break; @@ -245,7 +264,7 @@ export class PlanExecutor { } } else { // No step executor configured - fail fast - errors.push('No step executor configured'); + errors.push("No step executor configured"); break; } } @@ -256,7 +275,10 @@ export class PlanExecutor { const result: PlanTaskResult = { success: errors.length === 0, - output: errors.length === 0 ? `Task ${task.id} completed successfully` : `Task ${task.id} failed`, + output: + errors.length === 0 + ? `Task ${task.id} completed successfully` + : `Task ${task.id} failed`, filesCreated: filesCreated.length > 0 ? filesCreated : undefined, filesModified: filesModified.length > 0 ? filesModified : undefined, errors: errors.length > 0 ? errors : undefined, @@ -273,10 +295,15 @@ export class PlanExecutor { /** * Execute with timeout */ - private async executeWithTimeout(promise: Promise, timeoutMs: number): Promise { + private async executeWithTimeout( + promise: Promise, + timeoutMs: number, + ): Promise { return Promise.race([ promise, - new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs)), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), timeoutMs), + ), ]); } @@ -296,7 +323,10 @@ export class PlanExecutor { /** * Get execution order using topological sort */ - private getExecutionOrder(plan: StructuredPlan, graph: Map): number[] { + private getExecutionOrder( + plan: StructuredPlan, + graph: Map, + ): number[] { const order: number[] = []; const visited = new Set(); const temp = new Set(); @@ -331,12 +361,17 @@ export class PlanExecutor { /** * Check if dependencies are completed */ - private checkDependencies(task: PlanTask, result: PlanExecutionResult): boolean { + private checkDependencies( + task: PlanTask, + result: PlanExecutionResult, + ): boolean { if (!task.dependencies || task.dependencies.length === 0) { return true; } - return task.dependencies.every((depId) => result.completedTasks.includes(depId)); + return task.dependencies.every((depId) => + result.completedTasks.includes(depId), + ); } /** @@ -348,7 +383,7 @@ export class PlanExecutor { this.resumePromise = new Promise((resolve) => { this.resumeResolve = resolve; }); - this.emit('plan:paused', {} as StructuredPlan); + this.emit("plan:paused", {} as StructuredPlan); } } @@ -361,7 +396,7 @@ export class PlanExecutor { this.resumeResolve(); this.resumeResolve = undefined; this.resumePromise = undefined; - this.emit('plan:resumed', {} as StructuredPlan); + this.emit("plan:resumed", {} as StructuredPlan); } } @@ -406,7 +441,12 @@ export class PlanExecutor { /** * Emit event */ - private emit(event: PlanEvent, plan: StructuredPlan, task?: PlanTask, result?: PlanTaskResult): void { + private emit( + event: PlanEvent, + plan: StructuredPlan, + task?: PlanTask, + result?: PlanTaskResult, + ): void { for (const listener of this.listeners) { try { listener(event, plan, task, result); @@ -420,7 +460,9 @@ export class PlanExecutor { * Check if currently executing */ isExecuting(): boolean { - return this.abortController !== undefined && !this.abortController.signal.aborted; + return ( + this.abortController !== undefined && !this.abortController.signal.aborted + ); } /** @@ -434,7 +476,9 @@ export class PlanExecutor { /** * Create a PlanExecutor instance */ -export function createPlanExecutor(options?: { stepExecutor?: StepExecutor }): PlanExecutor { +export function createPlanExecutor(options?: { + stepExecutor?: StepExecutor; +}): PlanExecutor { return new PlanExecutor(options); } @@ -442,23 +486,46 @@ export function createPlanExecutor(options?: { stepExecutor?: StepExecutor }): P * Default step executor (dry run) */ export const dryRunExecutor: StepExecutor = async (step, task, _plan) => { - console.log(`[DRY RUN] Task ${task.id}, Step ${step.number}: ${step.description}`); - return { success: true, output: 'Dry run completed' }; + console.log( + `[DRY RUN] Task ${task.id}, Step ${step.number}: ${step.description}`, + ); + return { success: true, output: "Dry run completed" }; }; /** * Shell step executor (runs commands) + * + * Uses execFileSync for simple commands (no shell metacharacters) to prevent + * injection. Falls back to sh -c for commands that require shell features + * like pipes, redirects, or subshells — these come from plan configs, + * not user input. */ export const shellExecutor: StepExecutor = async (step, _task, _plan) => { if (!step.command) { - return { success: true, output: 'No command to execute' }; + return { success: true, output: "No command to execute" }; } try { - const { execSync } = await import('node:child_process'); - const output = execSync(step.command, { encoding: 'utf-8', timeout: 60000 }); + const needsShell = /[|><;`$(){}]/.test(step.command); + let output: string; + + if (needsShell) { + output = execFileSync("sh", ["-c", step.command], { + encoding: "utf-8", + timeout: 60000, + }); + } else { + const parts = splitCommand(step.command); + if (parts.length === 0) { + return { success: false, output: "", error: "Empty command" }; + } + const [cmd, ...args] = parts; + output = execFileSync(cmd, args, { + encoding: "utf-8", + timeout: 60000, + }); + } - // Check expected output if provided if (step.expectedOutput && !output.includes(step.expectedOutput)) { return { success: false, @@ -471,7 +538,7 @@ export const shellExecutor: StepExecutor = async (step, _task, _plan) => { } catch (error) { return { success: false, - output: '', + output: "", error: (error as Error).message, }; } diff --git a/packages/core/src/providers/bitbucket.ts b/packages/core/src/providers/bitbucket.ts index 940108b7..e3f7346b 100644 --- a/packages/core/src/providers/bitbucket.ts +++ b/packages/core/src/providers/bitbucket.ts @@ -1,35 +1,41 @@ -import { execSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; -import { join, basename } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import type { GitProviderAdapter, CloneOptions } from './base.js'; -import { parseShorthand } from './base.js'; -import type { GitProvider, CloneResult } from '../types.js'; -import { discoverSkills } from '../skills.js'; +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { GitProviderAdapter, CloneOptions } from "./base.js"; +import { parseShorthand } from "./base.js"; +import type { GitProvider, CloneResult } from "../types.js"; +import { discoverSkills } from "../skills.js"; export class BitbucketProvider implements GitProviderAdapter { - readonly type: GitProvider = 'bitbucket'; - readonly name = 'Bitbucket'; - readonly baseUrl = 'https://bitbucket.org'; - - parseSource(source: string): { owner: string; repo: string; subpath?: string } | null { - if (source.startsWith('https://bitbucket.org/')) { - const path = source.replace('https://bitbucket.org/', '').replace(/\.git$/, ''); + readonly type: GitProvider = "bitbucket"; + readonly name = "Bitbucket"; + readonly baseUrl = "https://bitbucket.org"; + + parseSource( + source: string, + ): { owner: string; repo: string; subpath?: string } | null { + if (source.startsWith("https://bitbucket.org/")) { + const path = source + .replace("https://bitbucket.org/", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('git@bitbucket.org:')) { - const path = source.replace('git@bitbucket.org:', '').replace(/\.git$/, ''); + if (source.startsWith("git@bitbucket.org:")) { + const path = source + .replace("git@bitbucket.org:", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('bitbucket:')) { - return parseShorthand(source.replace('bitbucket:', '')); + if (source.startsWith("bitbucket:")) { + return parseShorthand(source.replace("bitbucket:", "")); } - if (source.startsWith('bitbucket.org/')) { - return parseShorthand(source.replace('bitbucket.org/', '')); + if (source.startsWith("bitbucket.org/")) { + return parseShorthand(source.replace("bitbucket.org/", "")); } return null; @@ -37,10 +43,10 @@ export class BitbucketProvider implements GitProviderAdapter { matches(source: string): boolean { return ( - source.startsWith('https://bitbucket.org/') || - source.startsWith('git@bitbucket.org:') || - source.startsWith('bitbucket:') || - source.startsWith('bitbucket.org/') + source.startsWith("https://bitbucket.org/") || + source.startsWith("git@bitbucket.org:") || + source.startsWith("bitbucket:") || + source.startsWith("bitbucket.org/") ); } @@ -52,30 +58,36 @@ export class BitbucketProvider implements GitProviderAdapter { return `git@bitbucket.org:${owner}/${repo}.git`; } - async clone(source: string, _targetDir: string, options: CloneOptions = {}): Promise { + async clone( + source: string, + _targetDir: string, + options: CloneOptions = {}, + ): Promise { const parsed = this.parseSource(source); if (!parsed) { return { success: false, error: `Invalid Bitbucket source: ${source}` }; } const { owner, repo, subpath } = parsed; - const cloneUrl = options.ssh ? this.getSshUrl(owner, repo) : this.getCloneUrl(owner, repo); + const cloneUrl = options.ssh + ? this.getSshUrl(owner, repo) + : this.getCloneUrl(owner, repo); const tempDir = join(tmpdir(), `skillkit-${randomUUID()}`); try { - const args = ['clone']; + const args = ["clone"]; if (options.depth) { - args.push('--depth', String(options.depth)); + args.push("--depth", String(options.depth)); } if (options.branch) { - args.push('--branch', options.branch); + args.push("--branch", options.branch); } args.push(cloneUrl, tempDir); - execSync(`git ${args.join(' ')}`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', + execFileSync("git", args, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", }); const searchDir = subpath ? join(tempDir, subpath) : tempDir; @@ -85,8 +97,8 @@ export class BitbucketProvider implements GitProviderAdapter { success: true, path: searchDir, tempRoot: tempDir, - skills: skills.map(s => s.name), - discoveredSkills: skills.map(s => ({ + skills: skills.map((s) => s.name), + discoveredSkills: skills.map((s) => ({ name: s.name, dirName: basename(s.path), path: s.path, diff --git a/packages/core/src/providers/github.ts b/packages/core/src/providers/github.ts index dc2b4455..9cd984c7 100644 --- a/packages/core/src/providers/github.ts +++ b/packages/core/src/providers/github.ts @@ -1,30 +1,34 @@ -import { execSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; -import { join, basename } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import type { GitProviderAdapter, CloneOptions } from './base.js'; -import { parseShorthand, isGitUrl } from './base.js'; -import type { GitProvider, CloneResult } from '../types.js'; -import { discoverSkills } from '../skills.js'; +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { GitProviderAdapter, CloneOptions } from "./base.js"; +import { parseShorthand, isGitUrl } from "./base.js"; +import type { GitProvider, CloneResult } from "../types.js"; +import { discoverSkills } from "../skills.js"; export class GitHubProvider implements GitProviderAdapter { - readonly type: GitProvider = 'github'; - readonly name = 'GitHub'; - readonly baseUrl = 'https://github.com'; - - parseSource(source: string): { owner: string; repo: string; subpath?: string } | null { - if (source.startsWith('https://github.com/')) { - const path = source.replace('https://github.com/', '').replace(/\.git$/, ''); + readonly type: GitProvider = "github"; + readonly name = "GitHub"; + readonly baseUrl = "https://github.com"; + + parseSource( + source: string, + ): { owner: string; repo: string; subpath?: string } | null { + if (source.startsWith("https://github.com/")) { + const path = source + .replace("https://github.com/", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('git@github.com:')) { - const path = source.replace('git@github.com:', '').replace(/\.git$/, ''); + if (source.startsWith("git@github.com:")) { + const path = source.replace("git@github.com:", "").replace(/\.git$/, ""); return parseShorthand(path); } - if (!isGitUrl(source) && !source.includes(':')) { + if (!isGitUrl(source) && !source.includes(":")) { return parseShorthand(source); } @@ -33,9 +37,9 @@ export class GitHubProvider implements GitProviderAdapter { matches(source: string): boolean { return ( - source.startsWith('https://github.com/') || - source.startsWith('git@github.com:') || - (!isGitUrl(source) && !source.includes(':') && source.includes('/')) + source.startsWith("https://github.com/") || + source.startsWith("git@github.com:") || + (!isGitUrl(source) && !source.includes(":") && source.includes("/")) ); } @@ -47,30 +51,36 @@ export class GitHubProvider implements GitProviderAdapter { return `git@github.com:${owner}/${repo}.git`; } - async clone(source: string, _targetDir: string, options: CloneOptions = {}): Promise { + async clone( + source: string, + _targetDir: string, + options: CloneOptions = {}, + ): Promise { const parsed = this.parseSource(source); if (!parsed) { return { success: false, error: `Invalid GitHub source: ${source}` }; } const { owner, repo, subpath } = parsed; - const cloneUrl = options.ssh ? this.getSshUrl(owner, repo) : this.getCloneUrl(owner, repo); + const cloneUrl = options.ssh + ? this.getSshUrl(owner, repo) + : this.getCloneUrl(owner, repo); const tempDir = join(tmpdir(), `skillkit-${randomUUID()}`); try { - const args = ['clone']; + const args = ["clone"]; if (options.depth) { - args.push('--depth', String(options.depth)); + args.push("--depth", String(options.depth)); } if (options.branch) { - args.push('--branch', options.branch); + args.push("--branch", options.branch); } args.push(cloneUrl, tempDir); - execSync(`git ${args.join(' ')}`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', + execFileSync("git", args, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", }); const searchDir = subpath ? join(tempDir, subpath) : tempDir; @@ -80,8 +90,8 @@ export class GitHubProvider implements GitProviderAdapter { success: true, path: searchDir, tempRoot: tempDir, - skills: skills.map(s => s.name), - discoveredSkills: skills.map(s => ({ + skills: skills.map((s) => s.name), + discoveredSkills: skills.map((s) => ({ name: s.name, dirName: basename(s.path), path: s.path, diff --git a/packages/core/src/providers/gitlab.ts b/packages/core/src/providers/gitlab.ts index b96cbe81..e5390c4f 100644 --- a/packages/core/src/providers/gitlab.ts +++ b/packages/core/src/providers/gitlab.ts @@ -1,35 +1,39 @@ -import { execSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; -import { join, basename } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import type { GitProviderAdapter, CloneOptions } from './base.js'; -import { parseShorthand } from './base.js'; -import type { GitProvider, CloneResult } from '../types.js'; -import { discoverSkills } from '../skills.js'; +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { GitProviderAdapter, CloneOptions } from "./base.js"; +import { parseShorthand } from "./base.js"; +import type { GitProvider, CloneResult } from "../types.js"; +import { discoverSkills } from "../skills.js"; export class GitLabProvider implements GitProviderAdapter { - readonly type: GitProvider = 'gitlab'; - readonly name = 'GitLab'; - readonly baseUrl = 'https://gitlab.com'; - - parseSource(source: string): { owner: string; repo: string; subpath?: string } | null { - if (source.startsWith('https://gitlab.com/')) { - const path = source.replace('https://gitlab.com/', '').replace(/\.git$/, ''); + readonly type: GitProvider = "gitlab"; + readonly name = "GitLab"; + readonly baseUrl = "https://gitlab.com"; + + parseSource( + source: string, + ): { owner: string; repo: string; subpath?: string } | null { + if (source.startsWith("https://gitlab.com/")) { + const path = source + .replace("https://gitlab.com/", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('git@gitlab.com:')) { - const path = source.replace('git@gitlab.com:', '').replace(/\.git$/, ''); + if (source.startsWith("git@gitlab.com:")) { + const path = source.replace("git@gitlab.com:", "").replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('gitlab:')) { - return parseShorthand(source.replace('gitlab:', '')); + if (source.startsWith("gitlab:")) { + return parseShorthand(source.replace("gitlab:", "")); } - if (source.startsWith('gitlab.com/')) { - return parseShorthand(source.replace('gitlab.com/', '')); + if (source.startsWith("gitlab.com/")) { + return parseShorthand(source.replace("gitlab.com/", "")); } return null; @@ -37,10 +41,10 @@ export class GitLabProvider implements GitProviderAdapter { matches(source: string): boolean { return ( - source.startsWith('https://gitlab.com/') || - source.startsWith('git@gitlab.com:') || - source.startsWith('gitlab:') || - source.startsWith('gitlab.com/') + source.startsWith("https://gitlab.com/") || + source.startsWith("git@gitlab.com:") || + source.startsWith("gitlab:") || + source.startsWith("gitlab.com/") ); } @@ -52,30 +56,36 @@ export class GitLabProvider implements GitProviderAdapter { return `git@gitlab.com:${owner}/${repo}.git`; } - async clone(source: string, _targetDir: string, options: CloneOptions = {}): Promise { + async clone( + source: string, + _targetDir: string, + options: CloneOptions = {}, + ): Promise { const parsed = this.parseSource(source); if (!parsed) { return { success: false, error: `Invalid GitLab source: ${source}` }; } const { owner, repo, subpath } = parsed; - const cloneUrl = options.ssh ? this.getSshUrl(owner, repo) : this.getCloneUrl(owner, repo); + const cloneUrl = options.ssh + ? this.getSshUrl(owner, repo) + : this.getCloneUrl(owner, repo); const tempDir = join(tmpdir(), `skillkit-${randomUUID()}`); try { - const args = ['clone']; + const args = ["clone"]; if (options.depth) { - args.push('--depth', String(options.depth)); + args.push("--depth", String(options.depth)); } if (options.branch) { - args.push('--branch', options.branch); + args.push("--branch", options.branch); } args.push(cloneUrl, tempDir); - execSync(`git ${args.join(' ')}`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', + execFileSync("git", args, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", }); const searchDir = subpath ? join(tempDir, subpath) : tempDir; @@ -85,8 +95,8 @@ export class GitLabProvider implements GitProviderAdapter { success: true, path: searchDir, tempRoot: tempDir, - skills: skills.map(s => s.name), - discoveredSkills: skills.map(s => ({ + skills: skills.map((s) => s.name), + discoveredSkills: skills.map((s) => ({ name: s.name, dirName: basename(s.path), path: s.path, diff --git a/packages/core/src/recommend/fetcher.ts b/packages/core/src/recommend/fetcher.ts index 31a5a148..85e3328c 100644 --- a/packages/core/src/recommend/fetcher.ts +++ b/packages/core/src/recommend/fetcher.ts @@ -1,24 +1,42 @@ -import { execSync } from 'node:child_process'; -import { existsSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir, homedir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import type { SkillIndex, SkillSummary, IndexSource } from './types.js'; -import { discoverSkills, extractFrontmatter } from '../skills.js'; +import { execFileSync } from "node:child_process"; +import { + existsSync, + rmSync, + readFileSync, + writeFileSync, + mkdirSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { SkillIndex, SkillSummary, IndexSource } from "./types.js"; +import { discoverSkills, extractFrontmatter } from "../skills.js"; /** * Known skill repositories to index */ export const KNOWN_SKILL_REPOS = [ - { owner: 'anthropics', repo: 'courses', description: 'Anthropic official courses and skills' }, - { owner: 'vercel-labs', repo: 'ai-sdk-preview-internal-knowledge-base', description: 'Vercel AI SDK skills' }, - { owner: 'composioHQ', repo: 'awesome-claude-skills', description: 'Curated Claude Code skills' }, + { + owner: "anthropics", + repo: "courses", + description: "Anthropic official courses and skills", + }, + { + owner: "vercel-labs", + repo: "ai-sdk-preview-internal-knowledge-base", + description: "Vercel AI SDK skills", + }, + { + owner: "composioHQ", + repo: "awesome-claude-skills", + description: "Curated Claude Code skills", + }, ] as const; /** * Index file path */ -export const INDEX_PATH = join(homedir(), '.skillkit', 'index.json'); +export const INDEX_PATH = join(homedir(), ".skillkit", "index.json"); export const INDEX_CACHE_HOURS = 24; /** @@ -26,17 +44,24 @@ export const INDEX_CACHE_HOURS = 24; */ export async function fetchSkillsFromRepo( owner: string, - repo: string + repo: string, ): Promise<{ skills: SkillSummary[]; error?: string }> { + if (!/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo)) { + return { + skills: [], + error: `Invalid owner or repo name: ${owner}/${repo}`, + }; + } + const cloneUrl = `https://github.com/${owner}/${repo}.git`; const tempDir = join(tmpdir(), `skillkit-fetch-${randomUUID()}`); try { // Shallow clone for speed - execSync(`git clone --depth 1 ${cloneUrl} ${tempDir}`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', - timeout: 60000, // 60 second timeout + execFileSync("git", ["clone", "--depth", "1", cloneUrl, tempDir], { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", + timeout: 60000, }); // Discover skills in the cloned repo @@ -44,27 +69,36 @@ export async function fetchSkillsFromRepo( const skills: SkillSummary[] = []; for (const skill of discoveredSkills) { - const skillMdPath = join(skill.path, 'SKILL.md'); + const skillMdPath = join(skill.path, "SKILL.md"); if (!existsSync(skillMdPath)) continue; try { - const content = readFileSync(skillMdPath, 'utf-8'); + const content = readFileSync(skillMdPath, "utf-8"); const frontmatter = extractFrontmatter(content); const summary: SkillSummary = { name: skill.name, - description: skill.description || frontmatter?.description as string || 'No description', + description: + skill.description || + (frontmatter?.description as string) || + "No description", source: `${owner}/${repo}`, tags: (frontmatter?.tags as string[]) || [], compatibility: { - frameworks: (frontmatter?.compatibility as Record)?.frameworks as string[] || [], - languages: (frontmatter?.compatibility as Record)?.languages as string[] || [], - libraries: (frontmatter?.compatibility as Record)?.libraries as string[] || [], + frameworks: + ((frontmatter?.compatibility as Record) + ?.frameworks as string[]) || [], + languages: + ((frontmatter?.compatibility as Record) + ?.languages as string[]) || [], + libraries: + ((frontmatter?.compatibility as Record) + ?.libraries as string[]) || [], }, popularity: 0, quality: 50, lastUpdated: new Date().toISOString(), - verified: owner === 'anthropics' || owner === 'vercel-labs', + verified: owner === "anthropics" || owner === "vercel-labs", }; skills.push(summary); @@ -76,7 +110,10 @@ export async function fetchSkillsFromRepo( return { skills }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return { skills: [], error: `Failed to fetch ${owner}/${repo}: ${message}` }; + return { + skills: [], + error: `Failed to fetch ${owner}/${repo}: ${message}`, + }; } finally { // Clean up temp directory if (existsSync(tempDir)) { @@ -94,7 +131,7 @@ export async function fetchSkillsFromRepo( */ export async function buildSkillIndex( repos: typeof KNOWN_SKILL_REPOS = KNOWN_SKILL_REPOS, - onProgress?: (message: string) => void + onProgress?: (message: string) => void, ): Promise<{ index: SkillIndex; errors: string[] }> { const allSkills: SkillSummary[] = []; const sources: IndexSource[] = []; @@ -125,11 +162,11 @@ export async function buildSkillIndex( // Add fallback sample skills if no repos could be fetched if (allSkills.length === 0) { - onProgress?.('No skills fetched, using sample index...'); + onProgress?.("No skills fetched, using sample index..."); allSkills.push(...getSampleSkills()); sources.push({ - name: 'built-in', - url: 'https://github.com/rohitg00/skillkit', + name: "built-in", + url: "https://github.com/rohitg00/skillkit", lastFetched: new Date().toISOString(), skillCount: allSkills.length, }); @@ -149,7 +186,7 @@ export async function buildSkillIndex( * Save skill index to cache */ export function saveIndex(index: SkillIndex): void { - const indexDir = join(homedir(), '.skillkit'); + const indexDir = dirname(INDEX_PATH); if (!existsSync(indexDir)) { mkdirSync(indexDir, { recursive: true }); } @@ -165,7 +202,7 @@ export function loadIndex(): SkillIndex | null { } try { - const content = readFileSync(INDEX_PATH, 'utf-8'); + const content = readFileSync(INDEX_PATH, "utf-8"); return JSON.parse(content) as SkillIndex; } catch { return null; @@ -177,17 +214,18 @@ export function loadIndex(): SkillIndex | null { */ export function isIndexStale(index: SkillIndex): boolean { const lastUpdated = new Date(index.lastUpdated); - const hoursSinceUpdate = (Date.now() - lastUpdated.getTime()) / (1000 * 60 * 60); + const hoursSinceUpdate = + (Date.now() - lastUpdated.getTime()) / (1000 * 60 * 60); return hoursSinceUpdate > INDEX_CACHE_HOURS; } /** * Get index status */ -export function getIndexStatus(): 'missing' | 'stale' | 'fresh' { +export function getIndexStatus(): "missing" | "stale" | "fresh" { const index = loadIndex(); - if (!index) return 'missing'; - return isIndexStale(index) ? 'stale' : 'fresh'; + if (!index) return "missing"; + return isIndexStale(index) ? "stale" : "fresh"; } /** @@ -196,13 +234,14 @@ export function getIndexStatus(): 'missing' | 'stale' | 'fresh' { function getSampleSkills(): SkillSummary[] { return [ { - name: 'react-best-practices', - description: 'Modern React patterns including Server Components, hooks best practices, and performance optimization', - source: 'built-in', - tags: ['react', 'frontend', 'typescript', 'nextjs', 'performance'], + name: "react-best-practices", + description: + "Modern React patterns including Server Components, hooks best practices, and performance optimization", + source: "built-in", + tags: ["react", "frontend", "typescript", "nextjs", "performance"], compatibility: { - frameworks: ['react', 'nextjs'], - languages: ['typescript', 'javascript'], + frameworks: ["react", "nextjs"], + languages: ["typescript", "javascript"], libraries: [], }, popularity: 1500, @@ -211,14 +250,15 @@ function getSampleSkills(): SkillSummary[] { verified: true, }, { - name: 'tailwind-patterns', - description: 'Tailwind CSS utility patterns, responsive design, and component styling best practices', - source: 'built-in', - tags: ['tailwind', 'css', 'styling', 'frontend', 'responsive'], + name: "tailwind-patterns", + description: + "Tailwind CSS utility patterns, responsive design, and component styling best practices", + source: "built-in", + tags: ["tailwind", "css", "styling", "frontend", "responsive"], compatibility: { frameworks: [], - languages: ['typescript', 'javascript'], - libraries: ['tailwindcss'], + languages: ["typescript", "javascript"], + libraries: ["tailwindcss"], }, popularity: 1200, quality: 92, @@ -226,13 +266,14 @@ function getSampleSkills(): SkillSummary[] { verified: true, }, { - name: 'typescript-strict-patterns', - description: 'TypeScript strict mode patterns, type safety, and advanced type utilities', - source: 'built-in', - tags: ['typescript', 'types', 'safety', 'patterns'], + name: "typescript-strict-patterns", + description: + "TypeScript strict mode patterns, type safety, and advanced type utilities", + source: "built-in", + tags: ["typescript", "types", "safety", "patterns"], compatibility: { frameworks: [], - languages: ['typescript'], + languages: ["typescript"], libraries: [], }, popularity: 900, @@ -241,13 +282,14 @@ function getSampleSkills(): SkillSummary[] { verified: true, }, { - name: 'security-best-practices', - description: 'Security patterns for web applications including XSS prevention, CSRF, and secure headers', - source: 'built-in', - tags: ['security', 'xss', 'csrf', 'headers', 'owasp'], + name: "security-best-practices", + description: + "Security patterns for web applications including XSS prevention, CSRF, and secure headers", + source: "built-in", + tags: ["security", "xss", "csrf", "headers", "owasp"], compatibility: { frameworks: [], - languages: ['typescript', 'javascript', 'python'], + languages: ["typescript", "javascript", "python"], libraries: [], }, popularity: 600, @@ -256,14 +298,15 @@ function getSampleSkills(): SkillSummary[] { verified: true, }, { - name: 'testing-patterns', - description: 'Testing patterns with Vitest/Jest including mocking, assertions, and test organization', - source: 'built-in', - tags: ['vitest', 'jest', 'testing', 'typescript', 'mocking', 'tdd'], + name: "testing-patterns", + description: + "Testing patterns with Vitest/Jest including mocking, assertions, and test organization", + source: "built-in", + tags: ["vitest", "jest", "testing", "typescript", "mocking", "tdd"], compatibility: { frameworks: [], - languages: ['typescript', 'javascript'], - libraries: ['vitest', 'jest'], + languages: ["typescript", "javascript"], + libraries: ["vitest", "jest"], }, popularity: 700, quality: 86, diff --git a/packages/core/src/utils/shell.ts b/packages/core/src/utils/shell.ts new file mode 100644 index 00000000..5679588f --- /dev/null +++ b/packages/core/src/utils/shell.ts @@ -0,0 +1,59 @@ +/** + * POSIX-like shell word splitting that respects quoted substrings. + * Handles: simple words, "double quoted", 'single quoted', and + * --flag="value with spaces" correctly. + */ +export function splitCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + + if (inSingleQuote) { + if (ch === "'") { + inSingleQuote = false; + } else { + current += ch; + } + continue; + } + + if (inDoubleQuote) { + if (ch === '"') { + inDoubleQuote = false; + } else { + current += ch; + } + continue; + } + + if (ch === "'") { + inSingleQuote = true; + continue; + } + + if (ch === '"') { + inDoubleQuote = true; + continue; + } + + if (ch === " " || ch === "\t") { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + continue; + } + + current += ch; + } + + if (current.length > 0) { + tokens.push(current); + } + + return tokens; +} diff --git a/packages/mesh/src/peer/health.ts b/packages/mesh/src/peer/health.ts index 83a4fb6b..bcdb7ab7 100644 --- a/packages/mesh/src/peer/health.ts +++ b/packages/mesh/src/peer/health.ts @@ -1,7 +1,7 @@ -import got from 'got'; -import type { Host, HealthCheckResult, HostStatus } from '../types.js'; -import { HEALTH_CHECK_TIMEOUT } from '../types.js'; -import { getKnownHosts, updateKnownHost } from '../config/hosts-config.js'; +import got from "got"; +import type { Host, HealthCheckResult, HostStatus } from "../types.js"; +import { HEALTH_CHECK_TIMEOUT } from "../types.js"; +import { getKnownHosts, updateKnownHost } from "../config/hosts-config.js"; export interface HealthCheckOptions { timeout?: number; @@ -10,7 +10,7 @@ export interface HealthCheckOptions { export async function checkHostHealth( host: Host, - options: HealthCheckOptions = {} + options: HealthCheckOptions = {}, ): Promise { const timeout = options.timeout ?? HEALTH_CHECK_TIMEOUT; const startTime = Date.now(); @@ -19,38 +19,42 @@ export async function checkHostHealth( hostId: host.id, address: host.address, port: host.port, - status: 'unknown', + status: "unknown", latencyMs: 0, checkedAt: new Date().toISOString(), }; try { - const url = `http://${host.address}:${host.port}/health`; + const protocol = host.tls ? "https" : "http"; + const url = `${protocol}://${host.address}:${host.port}/health`; const response = await got.get(url, { timeout: { request: timeout }, retry: { limit: 0 }, throwHttpErrors: false, + ...(host.tls && host.tlsAllowSelfSigned + ? { https: { rejectUnauthorized: false } } + : {}), }); result.latencyMs = Date.now() - startTime; if (response.statusCode === 200) { - result.status = 'online'; + result.status = "online"; } else { - result.status = 'offline'; + result.status = "offline"; result.error = `HTTP ${response.statusCode}`; } } catch (err: any) { result.latencyMs = Date.now() - startTime; - result.status = 'offline'; - result.error = err.code || err.message || 'Connection failed'; + result.status = "offline"; + result.error = err.code || err.message || "Connection failed"; } if (options.updateStatus !== false) { await updateKnownHost(host.id, { status: result.status, - lastSeen: result.status === 'online' ? result.checkedAt : host.lastSeen, + lastSeen: result.status === "online" ? result.checkedAt : host.lastSeen, }); } @@ -58,44 +62,46 @@ export async function checkHostHealth( } export async function checkAllHostsHealth( - options: HealthCheckOptions = {} + options: HealthCheckOptions = {}, ): Promise { const hosts = await getKnownHosts(); - const results = await Promise.all(hosts.map(host => checkHostHealth(host, options))); + const results = await Promise.all( + hosts.map((host) => checkHostHealth(host, options)), + ); return results; } export async function getOnlineHosts(): Promise { const hosts = await getKnownHosts(); - return hosts.filter(h => h.status === 'online'); + return hosts.filter((h) => h.status === "online"); } export async function getOfflineHosts(): Promise { const hosts = await getKnownHosts(); - return hosts.filter(h => h.status === 'offline'); + return hosts.filter((h) => h.status === "offline"); } export async function waitForHost( host: Host, maxWaitMs = 30000, - intervalMs = 1000 + intervalMs = 1000, ): Promise { const deadline = Date.now() + maxWaitMs; while (Date.now() < deadline) { const result = await checkHostHealth(host, { updateStatus: false }); - if (result.status === 'online') { + if (result.status === "online") { await updateKnownHost(host.id, { - status: 'online', + status: "online", lastSeen: new Date().toISOString(), }); return true; } - await new Promise(resolve => setTimeout(resolve, intervalMs)); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); } return false; @@ -105,9 +111,21 @@ export class HealthMonitor { private interval: NodeJS.Timeout | null = null; private running = false; private checking = false; - private onStatusChange?: (host: Host, oldStatus: HostStatus, newStatus: HostStatus) => void; - - constructor(options: { onStatusChange?: (host: Host, oldStatus: HostStatus, newStatus: HostStatus) => void } = {}) { + private onStatusChange?: ( + host: Host, + oldStatus: HostStatus, + newStatus: HostStatus, + ) => void; + + constructor( + options: { + onStatusChange?: ( + host: Host, + oldStatus: HostStatus, + newStatus: HostStatus, + ) => void; + } = {}, + ) { this.onStatusChange = options.onStatusChange; } diff --git a/packages/mesh/src/security/tls.ts b/packages/mesh/src/security/tls.ts index 990cdda2..b20f400d 100644 --- a/packages/mesh/src/security/tls.ts +++ b/packages/mesh/src/security/tls.ts @@ -1,11 +1,8 @@ -import { - generateKeyPairSync, - createHash, -} from 'node:crypto'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { generateKeyPairSync, createHash } from "node:crypto"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; export interface TLSContextOptions { cert?: string; @@ -31,26 +28,26 @@ export interface TLSConfig { rejectUnauthorized?: boolean; } -const DEFAULT_CERT_PATH = join(homedir(), '.skillkit', 'mesh', 'certs'); +const DEFAULT_CERT_PATH = join(homedir(), ".skillkit", "mesh", "certs"); function generateSelfSignedCertificate( hostId: string, hostName: string, - validDays: number = 365 + validDays: number = 365, ): { cert: string; key: string } { - const { publicKey, privateKey } = generateKeyPairSync('rsa', { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, }); const notBefore = new Date(); const notAfter = new Date(); notAfter.setDate(notAfter.getDate() + validDays); - const serialNumber = createHash('sha256') + const serialNumber = createHash("sha256") .update(hostId + Date.now().toString()) - .digest('hex') + .digest("hex") .slice(0, 16); const certPem = createSimpleCert({ @@ -61,7 +58,7 @@ function generateSelfSignedCertificate( serialNumber, notBefore, notAfter, - altNames: ['localhost', '127.0.0.1', hostName], + altNames: ["localhost", "127.0.0.1", hostName], }); return { @@ -83,15 +80,15 @@ interface CertParams { function createSimpleCert(params: CertParams): string { const base64Encode = (str: string): string => - Buffer.from(str).toString('base64'); + Buffer.from(str).toString("base64"); const formatDate = (date: Date): string => { const y = date.getUTCFullYear().toString().slice(-2); - const m = (date.getUTCMonth() + 1).toString().padStart(2, '0'); - const d = date.getUTCDate().toString().padStart(2, '0'); - const h = date.getUTCHours().toString().padStart(2, '0'); - const min = date.getUTCMinutes().toString().padStart(2, '0'); - const s = date.getUTCSeconds().toString().padStart(2, '0'); + const m = (date.getUTCMonth() + 1).toString().padStart(2, "0"); + const d = date.getUTCDate().toString().padStart(2, "0"); + const h = date.getUTCHours().toString().padStart(2, "0"); + const min = date.getUTCMinutes().toString().padStart(2, "0"); + const s = date.getUTCSeconds().toString().padStart(2, "0"); return `${y}${m}${d}${h}${min}${s}Z`; }; @@ -109,13 +106,13 @@ function createSimpleCert(params: CertParams): string { const certData = JSON.stringify(certInfo); const certBase64 = base64Encode(certData); - const lines: string[] = ['-----BEGIN CERTIFICATE-----']; + const lines: string[] = ["-----BEGIN CERTIFICATE-----"]; for (let i = 0; i < certBase64.length; i += 64) { lines.push(certBase64.slice(i, i + 64)); } - lines.push('-----END CERTIFICATE-----'); + lines.push("-----END CERTIFICATE-----"); - return lines.join('\n'); + return lines.join("\n"); } export class TLSManager { @@ -133,15 +130,15 @@ export class TLSManager { async generateCertificate( hostId: string, - hostName: string = 'localhost', - validDays: number = 365 + hostName: string = "localhost", + validDays: number = 365, ): Promise { await this.ensureDirectory(); const { cert, key } = generateSelfSignedCertificate( hostId, hostName, - validDays + validDays, ); const certFile = join(this.certPath, `${hostId}.crt`); @@ -150,7 +147,7 @@ export class TLSManager { await writeFile(certFile, cert, { mode: 0o644 }); await writeFile(keyFile, key, { mode: 0o600 }); - const fingerprint = createHash('sha256').update(cert).digest('hex'); + const fingerprint = createHash("sha256").update(cert).digest("hex"); const notBefore = new Date(); const notAfter = new Date(); notAfter.setDate(notAfter.getDate() + validDays); @@ -173,9 +170,9 @@ export class TLSManager { return null; } - const cert = await readFile(certFile, 'utf-8'); - const key = await readFile(keyFile, 'utf-8'); - const fingerprint = createHash('sha256').update(cert).digest('hex'); + const cert = await readFile(certFile, "utf-8"); + const key = await readFile(keyFile, "utf-8"); + const fingerprint = createHash("sha256").update(cert).digest("hex"); return { cert, @@ -189,7 +186,7 @@ export class TLSManager { async loadOrCreateCertificate( hostId: string, - hostName: string = 'localhost' + hostName: string = "localhost", ): Promise { const existing = await this.loadCertificate(hostId); if (existing) { @@ -210,15 +207,25 @@ export class TLSManager { }; } + /** + * @param options.allowSelfSigned - WARNING: disables certificate validation + * (rejectUnauthorized=false). Only safe for local development/testing. + * Defaults to true since the mesh system generates self-signed certs. + */ createServerContext( certInfo: CertificateInfo, - options?: { requestClientCert?: boolean; trustedCAs?: string[] } + options?: { + requestClientCert?: boolean; + trustedCAs?: string[]; + allowSelfSigned?: boolean; + }, ): TLSContextOptions { + const allowSelfSigned = options?.allowSelfSigned ?? true; const context: TLSContextOptions = { cert: certInfo.cert, key: certInfo.key, requestCert: options?.requestClientCert ?? false, - rejectUnauthorized: false, + rejectUnauthorized: !allowSelfSigned, }; if (options?.trustedCAs?.length) { @@ -231,10 +238,15 @@ export class TLSManager { createClientContext( certInfo?: CertificateInfo, - options?: { serverFingerprint?: string; trustedCAs?: string[] } + options?: { + serverFingerprint?: string; + trustedCAs?: string[]; + allowSelfSigned?: boolean; + }, ): TLSContextOptions { + const allowSelfSigned = options?.allowSelfSigned ?? true; const context: TLSContextOptions = { - rejectUnauthorized: false, + rejectUnauthorized: !allowSelfSigned, }; if (certInfo) { @@ -244,18 +256,19 @@ export class TLSManager { if (options?.trustedCAs?.length) { context.ca = options.trustedCAs; + context.rejectUnauthorized = true; } return context; } static computeCertFingerprint(cert: string): string { - return createHash('sha256').update(cert).digest('hex'); + return createHash("sha256").update(cert).digest("hex"); } static verifyCertFingerprint( cert: string, - expectedFingerprint: string + expectedFingerprint: string, ): boolean { const actual = TLSManager.computeCertFingerprint(cert); return actual.toLowerCase() === expectedFingerprint.toLowerCase(); diff --git a/packages/mesh/src/transport/secure-http.ts b/packages/mesh/src/transport/secure-http.ts index c4234ef3..fe7459aa 100644 --- a/packages/mesh/src/transport/secure-http.ts +++ b/packages/mesh/src/transport/secure-http.ts @@ -1,18 +1,18 @@ -import got, { type Got } from 'got'; -import { randomUUID } from 'node:crypto'; +import got, { type Got } from "got"; +import { randomUUID } from "node:crypto"; import type { TransportMessage, SecureTransportMessage, Host, -} from '../types.js'; -import { HEALTH_CHECK_TIMEOUT } from '../types.js'; -import { PeerIdentity } from '../crypto/identity.js'; +} from "../types.js"; +import { HEALTH_CHECK_TIMEOUT } from "../types.js"; +import { PeerIdentity } from "../crypto/identity.js"; +import { AuthManager, createBearerHeader } from "../security/auth.js"; import { - AuthManager, - createBearerHeader, -} from '../security/auth.js'; -import { type MeshSecurityConfig, DEFAULT_SECURITY_CONFIG } from '../security/config.js'; -import { SecureKeystore } from '../crypto/keystore.js'; + type MeshSecurityConfig, + DEFAULT_SECURITY_CONFIG, +} from "../security/config.js"; +import { SecureKeystore } from "../crypto/keystore.js"; export interface SecureHttpTransportOptions { timeout?: number; @@ -48,8 +48,7 @@ export class SecureHttpTransport { this.authToken = options.authToken ?? null; this.security = options.security ?? DEFAULT_SECURITY_CONFIG; - const protocol = - this.security.transport.tls !== 'none' ? 'https' : 'http'; + const protocol = this.security.transport.tls !== "none" ? "https" : "http"; const baseUrl = `${protocol}://${host.address}:${host.port}`; this.client = got.extend({ @@ -60,11 +59,13 @@ export class SecureHttpTransport { calculateDelay: () => this.options.retryDelay!, }, https: { - rejectUnauthorized: false, + // MITM risk: disabling verification for self-signed mode. + // Ideally supply the CA cert via the `ca` option instead. + rejectUnauthorized: this.security.transport.tls !== "self-signed", }, headers: { - 'Content-Type': 'application/json', - 'X-SkillKit-Transport': 'secure-http', + "Content-Type": "application/json", + "X-SkillKit-Transport": "secure-http", ...this.options.headers, }, }); @@ -88,11 +89,11 @@ export class SecureHttpTransport { const headers: Record = {}; if (this.authToken) { - headers['Authorization'] = createBearerHeader(this.authToken); + headers["Authorization"] = createBearerHeader(this.authToken); } if (this.identity) { - headers['X-SkillKit-Fingerprint'] = this.identity.fingerprint; + headers["X-SkillKit-Fingerprint"] = this.identity.fingerprint; } return headers; @@ -105,8 +106,8 @@ export class SecureHttpTransport { const message: TransportMessage = { id: randomUUID(), - type: 'request', - from: this.identity?.fingerprint ?? 'local', + type: "request", + from: this.identity?.fingerprint ?? "local", to: path, payload, timestamp: new Date().toISOString(), @@ -138,9 +139,9 @@ export class SecureHttpTransport { async sendMessage( to: string, type: string, - payload: unknown + payload: unknown, ): Promise { - return this.send('message', { + return this.send("message", { to, type, payload, @@ -148,7 +149,7 @@ export class SecureHttpTransport { } async registerPeer(registration: unknown): Promise { - return this.send('peer/register', registration); + return this.send("peer/register", registration); } async getPeers(): Promise { @@ -158,7 +159,7 @@ export class SecureHttpTransport { const secureHeaders = await this.getSecureHeaders(); - const response = await this.client.get('peers', { + const response = await this.client.get("peers", { headers: secureHeaders, }); @@ -167,7 +168,7 @@ export class SecureHttpTransport { async healthCheck(): Promise { try { - const response = await this.client.get('health', { + const response = await this.client.get("health", { headers: await this.getSecureHeaders(), }); return response.statusCode === 200; @@ -189,7 +190,7 @@ export async function sendToHostSecure( host: Host, path: string, payload: unknown, - options: SecureHttpTransportOptions = {} + options: SecureHttpTransportOptions = {}, ): Promise { const transport = new SecureHttpTransport(host, options); await transport.initialize(); @@ -200,7 +201,7 @@ export async function broadcastToHostsSecure( hosts: Host[], path: string, payload: unknown, - options: SecureHttpTransportOptions = {} + options: SecureHttpTransportOptions = {}, ): Promise> { const results = new Map(); @@ -212,25 +213,49 @@ export async function broadcastToHostsSecure( } catch (err) { results.set(host.id, err as Error); } - }) + }), ); return results; } -export function verifySecureMessage( - message: SecureTransportMessage -): { valid: boolean; error?: string } { - if (!message.signature || !message.senderPublicKey || !message.senderFingerprint) { - return { valid: false, error: 'Missing signature fields' }; +export async function verifySecureMessage( + message: SecureTransportMessage, +): Promise<{ + valid: boolean; + error?: string; +}> { + if ( + !message.signature || + !message.senderPublicKey || + !message.senderFingerprint + ) { + return { valid: false, error: "Missing signature fields" }; } const computedFingerprint = PeerIdentity.computeFingerprint( - Buffer.from(message.senderPublicKey, 'hex') + Buffer.from(message.senderPublicKey, "hex"), ); if (computedFingerprint !== message.senderFingerprint) { - return { valid: false, error: 'Fingerprint mismatch' }; + return { valid: false, error: "Fingerprint mismatch" }; + } + + const { + signature, + senderFingerprint, + senderPublicKey, + nonce, + ...baseMessage + } = message; + const messageJson = JSON.stringify(baseMessage); + const isValidSignature = await PeerIdentity.verifyHex( + signature, + Buffer.from(messageJson).toString("hex"), + senderPublicKey, + ); + if (!isValidSignature) { + return { valid: false, error: "Invalid signature" }; } return { valid: true }; diff --git a/packages/mesh/src/transport/secure-websocket.ts b/packages/mesh/src/transport/secure-websocket.ts index 842bf7c5..99080c36 100644 --- a/packages/mesh/src/transport/secure-websocket.ts +++ b/packages/mesh/src/transport/secure-websocket.ts @@ -1,19 +1,29 @@ -import WebSocket, { WebSocketServer } from 'ws'; -import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https'; -import { randomUUID } from 'node:crypto'; +import WebSocket, { WebSocketServer } from "ws"; +import { + createServer as createHttpsServer, + type Server as HttpsServer, +} from "node:https"; +import { randomUUID } from "node:crypto"; import type { TransportMessage, SecureTransportMessage, Host, -} from '../types.js'; -import { DEFAULT_PORT } from '../types.js'; -import { PeerIdentity } from '../crypto/identity.js'; -import { MessageEncryption } from '../crypto/encryption.js'; -import { AuthManager, type AuthChallengeRequest, type AuthChallengeResponse } from '../security/auth.js'; -import { TLSManager, type CertificateInfo } from '../security/tls.js'; -import { type MeshSecurityConfig, DEFAULT_SECURITY_CONFIG } from '../security/config.js'; -import { SecureKeystore } from '../crypto/keystore.js'; -import { hexToBytes } from '@noble/hashes/utils'; +} from "../types.js"; +import { DEFAULT_PORT } from "../types.js"; +import { PeerIdentity } from "../crypto/identity.js"; +import { MessageEncryption } from "../crypto/encryption.js"; +import { + AuthManager, + type AuthChallengeRequest, + type AuthChallengeResponse, +} from "../security/auth.js"; +import { TLSManager, type CertificateInfo } from "../security/tls.js"; +import { + type MeshSecurityConfig, + DEFAULT_SECURITY_CONFIG, +} from "../security/config.js"; +import { SecureKeystore } from "../crypto/keystore.js"; +import { hexToBytes } from "@noble/hashes/utils"; export interface SecureWebSocketOptions { timeout?: number; @@ -28,7 +38,7 @@ export interface SecureWebSocketOptions { export type SecureMessageHandler = ( message: TransportMessage, socket: WebSocket, - senderFingerprint?: string + senderFingerprint?: string, ) => void; interface AuthenticatedClient { @@ -41,7 +51,9 @@ interface AuthenticatedClient { export class SecureWebSocketTransport { private socket: WebSocket | null = null; private host: Host; - private options: Required>; + private options: Required< + Omit + >; private identity: PeerIdentity | null = null; private keystore: SecureKeystore | null = null; private authManager: AuthManager | null = null; @@ -77,7 +89,7 @@ export class SecureWebSocketTransport { private getUrl(): string { const protocol = - this.options.security.transport.tls !== 'none' ? 'wss' : 'ws'; + this.options.security.transport.tls !== "none" ? "wss" : "ws"; return `${protocol}://${this.host.address}:${this.host.port}/ws`; } @@ -90,17 +102,18 @@ export class SecureWebSocketTransport { const url = this.getUrl(); const wsOptions: WebSocket.ClientOptions = { handshakeTimeout: this.options.timeout, - rejectUnauthorized: false, + rejectUnauthorized: + this.options.security.transport.tls === "self-signed" ? false : true, }; this.socket = new WebSocket(url, wsOptions); const timeout = setTimeout(() => { this.socket?.close(); - reject(new Error('Connection timeout')); + reject(new Error("Connection timeout")); }, this.options.timeout); - this.socket.on('open', async () => { + this.socket.on("open", async () => { clearTimeout(timeout); this.connected = true; this.reconnectAttempts = 0; @@ -121,11 +134,11 @@ export class SecureWebSocketTransport { resolve(); }); - this.socket.on('message', async (data) => { + this.socket.on("message", async (data) => { try { const raw = JSON.parse(data.toString()); - if (raw.type === 'auth:challenge') { + if (raw.type === "auth:challenge") { return; } @@ -133,10 +146,12 @@ export class SecureWebSocketTransport { let senderFingerprint: string | undefined; if (this.encryption && raw.ciphertext) { - const decrypted = this.encryption.decryptToObject({ - nonce: raw.nonce, - ciphertext: raw.ciphertext, - }); + const decrypted = this.encryption.decryptToObject( + { + nonce: raw.nonce, + ciphertext: raw.ciphertext, + }, + ); message = decrypted; senderFingerprint = raw.senderFingerprint; } else if (raw.signature) { @@ -155,13 +170,14 @@ export class SecureWebSocketTransport { } this.messageHandlers.forEach((handler) => - handler(message, this.socket!, senderFingerprint) + handler(message, this.socket!, senderFingerprint), ); - } catch { + } catch (err) { + console.error("[SecureWebSocket] client message handler error:", err); } }); - this.socket.on('close', () => { + this.socket.on("close", () => { this.connected = false; this.authenticated = false; if (this.options.reconnect) { @@ -169,7 +185,7 @@ export class SecureWebSocketTransport { } }); - this.socket.on('error', (err) => { + this.socket.on("error", (err) => { clearTimeout(timeout); if (!this.connected) { reject(err); @@ -183,43 +199,49 @@ export class SecureWebSocketTransport { const handleChallenge = async (data: WebSocket.Data) => { try { const msg = JSON.parse(data.toString()); - if (msg.type === 'auth:challenge') { + if (msg.type === "auth:challenge") { const challenge: AuthChallengeRequest = { challenge: msg.challenge, timestamp: msg.timestamp, }; - const response = await this.authManager!.respondToChallenge(challenge); + const response = + await this.authManager!.respondToChallenge(challenge); this.socket!.send( JSON.stringify({ - type: 'auth:response', + type: "auth:response", ...response, - }) + }), ); - } else if (msg.type === 'auth:success') { - this.socket!.off('message', handleChallenge); + } else if (msg.type === "auth:success") { + this.socket!.off("message", handleChallenge); + clearTimeout(authTimeout); if (msg.serverPublicKey) { const serverPubKey = hexToBytes(msg.serverPublicKey); - const sharedSecret = this.identity!.deriveSharedSecret(serverPubKey); + const sharedSecret = + this.identity!.deriveSharedSecret(serverPubKey); this.encryption = new MessageEncryption(sharedSecret); } resolve(); - } else if (msg.type === 'auth:failed') { - this.socket!.off('message', handleChallenge); - reject(new Error(msg.error || 'Authentication failed')); + } else if (msg.type === "auth:failed") { + this.socket!.off("message", handleChallenge); + clearTimeout(authTimeout); + reject(new Error(msg.error || "Authentication failed")); } } catch (err) { + this.socket!.off("message", handleChallenge); + clearTimeout(authTimeout); reject(err); } }; - this.socket!.on('message', handleChallenge); + this.socket!.on("message", handleChallenge); - setTimeout(() => { - this.socket!.off('message', handleChallenge); - reject(new Error('Authentication timeout')); + const authTimeout = setTimeout(() => { + this.socket!.off("message", handleChallenge); + reject(new Error("Authentication timeout")); }, this.options.timeout); }); } @@ -241,14 +263,14 @@ export class SecureWebSocketTransport { } async send( - message: Omit + message: Omit, ): Promise { if (!this.socket || !this.connected) { - throw new Error('Not connected'); + throw new Error("Not connected"); } if (!this.authenticated) { - throw new Error('Not authenticated'); + throw new Error("Not authenticated"); } const fullMessage: TransportMessage = { @@ -261,7 +283,7 @@ export class SecureWebSocketTransport { if ( this.encryption && - this.options.security.transport.encryption === 'required' + this.options.security.transport.encryption === "required" ) { const encrypted = this.encryption.encryptObject(fullMessage); dataToSend = JSON.stringify({ @@ -348,7 +370,7 @@ export class SecureWebSocketServer { identity?: PeerIdentity; keystore?: SecureKeystore; hostId?: string; - } = {} + } = {}, ) { this.port = port; this.security = options.security ?? DEFAULT_SECURITY_CONFIG; @@ -366,11 +388,11 @@ export class SecureWebSocketServer { } this.authManager = new AuthManager(this.identity); - if (this.security.transport.tls !== 'none') { + if (this.security.transport.tls !== "none") { this.tlsManager = new TLSManager(); this.certInfo = await this.tlsManager.loadOrCreateCertificate( this.hostId, - 'localhost' + "localhost", ); } } @@ -381,7 +403,15 @@ export class SecureWebSocketServer { await this.initialize(); return new Promise((resolve, reject) => { - if (this.security.transport.tls !== 'none' && this.certInfo) { + if (this.security.transport.tls !== "none") { + if (!this.certInfo) { + reject( + new Error( + "TLS is configured but certificate is not available; refusing to start in plaintext", + ), + ); + return; + } this.httpsServer = createHttpsServer({ cert: this.certInfo.cert, key: this.certInfo.key, @@ -389,7 +419,7 @@ export class SecureWebSocketServer { this.wss = new WebSocketServer({ server: this.httpsServer, - path: '/ws', + path: "/ws", }); this.httpsServer.listen(this.port, () => { @@ -397,19 +427,19 @@ export class SecureWebSocketServer { resolve(); }); - this.httpsServer.on('error', reject); + this.httpsServer.on("error", reject); } else { - this.wss = new WebSocketServer({ port: this.port, path: '/ws' }); + this.wss = new WebSocketServer({ port: this.port, path: "/ws" }); - this.wss.on('listening', () => { + this.wss.on("listening", () => { this.running = true; resolve(); }); - this.wss.on('error', reject); + this.wss.on("error", reject); } - this.wss.on('connection', (socket) => { + this.wss.on("connection", (socket) => { this.handleConnection(socket); }); }); @@ -427,16 +457,16 @@ export class SecureWebSocketServer { } else { this.clients.set(socket, { socket, - fingerprint: 'anonymous', - publicKey: '', + fingerprint: "anonymous", + publicKey: "", }); } - socket.on('message', async (data) => { + socket.on("message", async (data) => { try { const raw = JSON.parse(data.toString()); - if (raw.type === 'auth:response') { + if (raw.type === "auth:response") { return; } @@ -447,10 +477,12 @@ export class SecureWebSocketServer { let senderFingerprint: string | undefined = client.fingerprint; if (client.encryption && raw.ciphertext) { - const decrypted = client.encryption.decryptToObject({ - nonce: raw.nonce, - ciphertext: raw.ciphertext, - }); + const decrypted = client.encryption.decryptToObject( + { + nonce: raw.nonce, + ciphertext: raw.ciphertext, + }, + ); message = decrypted; } else if (raw.signature) { const secure = raw as SecureTransportMessage; @@ -468,41 +500,46 @@ export class SecureWebSocketServer { } this.messageHandlers.forEach((handler) => - handler(message, socket, senderFingerprint) + handler(message, socket, senderFingerprint), ); - } catch { + } catch (err) { + console.error("[SecureWebSocket] server message handler error:", err); } }); - socket.on('close', () => { + socket.on("error", (err) => { + console.error("[SecureWebSocket] socket error:", err); + }); + + socket.on("close", () => { this.clients.delete(socket); }); } private async performServerHandshake( - socket: WebSocket + socket: WebSocket, ): Promise { return new Promise((resolve, reject) => { const challenge = this.authManager!.createChallenge(); socket.send( JSON.stringify({ - type: 'auth:challenge', + type: "auth:challenge", ...challenge, - }) + }), ); const timeout = setTimeout(() => { - socket.off('message', handleResponse); - reject(new Error('Handshake timeout')); + socket.off("message", handleResponse); + reject(new Error("Handshake timeout")); }, 10000); const handleResponse = async (data: WebSocket.Data) => { try { const msg = JSON.parse(data.toString()); - if (msg.type === 'auth:response') { + if (msg.type === "auth:response") { clearTimeout(timeout); - socket.off('message', handleResponse); + socket.off("message", handleResponse); const response: AuthChallengeResponse = { challenge: msg.challenge, @@ -518,41 +555,44 @@ export class SecureWebSocketServer { if (!result.authenticated) { socket.send( JSON.stringify({ - type: 'auth:failed', + type: "auth:failed", error: result.error, - }) + }), ); reject(new Error(result.error)); return; } if (this.keystore) { - const isRevoked = await this.keystore.isRevoked(result.fingerprint!); + const isRevoked = await this.keystore.isRevoked( + result.fingerprint!, + ); if (isRevoked) { socket.send( JSON.stringify({ - type: 'auth:failed', - error: 'Peer is revoked', - }) + type: "auth:failed", + error: "Peer is revoked", + }), ); - reject(new Error('Peer is revoked')); + reject(new Error("Peer is revoked")); return; } } let encryption: MessageEncryption | undefined; - if (this.security.transport.encryption === 'required') { + if (this.security.transport.encryption === "required") { const clientPubKey = hexToBytes(response.publicKey); - const sharedSecret = this.identity!.deriveSharedSecret(clientPubKey); + const sharedSecret = + this.identity!.deriveSharedSecret(clientPubKey); encryption = new MessageEncryption(sharedSecret); } socket.send( JSON.stringify({ - type: 'auth:success', + type: "auth:success", serverFingerprint: this.identity!.fingerprint, serverPublicKey: this.identity!.publicKeyHex, - }) + }), ); resolve({ @@ -568,7 +608,7 @@ export class SecureWebSocketServer { } }; - socket.on('message', handleResponse); + socket.on("message", handleResponse); }); } @@ -588,7 +628,7 @@ export class SecureWebSocketServer { } async broadcast( - message: Omit + message: Omit, ): Promise { const fullMessage: TransportMessage = { ...message, @@ -596,6 +636,19 @@ export class SecureWebSocketServer { timestamp: new Date().toISOString(), }; + let sharedSignedData: string | null = null; + if (this.identity) { + const signature = await this.identity.signObject(fullMessage); + const secureMessage: SecureTransportMessage = { + ...fullMessage, + signature, + senderFingerprint: this.identity.fingerprint, + senderPublicKey: this.identity.publicKeyHex, + nonce: randomUUID(), + }; + sharedSignedData = JSON.stringify(secureMessage); + } + for (const [socket, client] of this.clients) { if (socket.readyState !== WebSocket.OPEN) continue; @@ -610,27 +663,22 @@ export class SecureWebSocketServer { ciphertext: encrypted.ciphertext, timestamp: fullMessage.timestamp, }); - } else if (this.identity) { - const signature = await this.identity.signObject(fullMessage); - const secureMessage: SecureTransportMessage = { - ...fullMessage, - signature, - senderFingerprint: this.identity.fingerprint, - senderPublicKey: this.identity.publicKeyHex, - nonce: randomUUID(), - }; - dataToSend = JSON.stringify(secureMessage); + } else if (sharedSignedData) { + dataToSend = sharedSignedData; } else { dataToSend = JSON.stringify(fullMessage); } - socket.send(dataToSend); + socket.send(dataToSend, (err) => { + if (err) + console.error("[SecureWebSocketServer] broadcast send error:", err); + }); } } async sendTo( socket: WebSocket, - message: Omit + message: Omit, ): Promise { if (socket.readyState !== WebSocket.OPEN) return; @@ -668,7 +716,9 @@ export class SecureWebSocketServer { dataToSend = JSON.stringify(fullMessage); } - socket.send(dataToSend); + socket.send(dataToSend, (err) => { + if (err) console.error("[SecureWebSocketServer] sendTo error:", err); + }); } onMessage(handler: SecureMessageHandler): () => void { diff --git a/packages/mesh/src/types.ts b/packages/mesh/src/types.ts index 9c42e39b..6ccebeeb 100644 --- a/packages/mesh/src/types.ts +++ b/packages/mesh/src/types.ts @@ -1,4 +1,4 @@ -export type HostStatus = 'online' | 'offline' | 'unknown'; +export type HostStatus = "online" | "offline" | "unknown"; export interface Host { id: string; @@ -10,6 +10,8 @@ export interface Host { lastSeen: string; version?: string; metadata?: Record; + tls?: boolean; + tlsAllowSelfSigned?: boolean; } export interface Peer { @@ -46,7 +48,7 @@ export interface HostsFile { } export interface DiscoveryMessage { - type: 'announce' | 'query' | 'response'; + type: "announce" | "query" | "response"; hostId: string; hostName: string; address: string; @@ -121,4 +123,4 @@ export const DEFAULT_PORT = 9876; export const DEFAULT_DISCOVERY_PORT = 9877; export const HEALTH_CHECK_TIMEOUT = 5000; export const DISCOVERY_INTERVAL = 30000; -export const MESH_VERSION = '1.7.11'; +export const MESH_VERSION = "1.7.11"; diff --git a/packages/messaging/src/transport/remote.ts b/packages/messaging/src/transport/remote.ts index 2cce25a1..29f09bda 100644 --- a/packages/messaging/src/transport/remote.ts +++ b/packages/messaging/src/transport/remote.ts @@ -1,9 +1,11 @@ -import got from 'got'; -import type { Message, MessageDeliveryResult } from '../types.js'; +import got from "got"; +import type { Message, MessageDeliveryResult } from "../types.js"; export interface RemoteTransportOptions { timeout?: number; retries?: number; + useHttps?: boolean; + rejectUnauthorized?: boolean; } export class RemoteTransport { @@ -13,15 +15,31 @@ export class RemoteTransport { this.options = { timeout: options.timeout ?? 10000, retries: options.retries ?? 2, + useHttps: options.useHttps ?? false, + rejectUnauthorized: options.rejectUnauthorized ?? true, }; } + private getProtocol(hostAddress: string): string { + const isLocalhost = + hostAddress === "localhost" || + hostAddress === "127.0.0.1" || + hostAddress === "::1"; + return this.options.useHttps && !isLocalhost ? "https" : "http"; + } + + private formatHost(hostAddress: string): string { + return hostAddress.includes(":") && !hostAddress.startsWith("[") + ? `[${hostAddress}]` + : hostAddress; + } + async deliver( message: Message, hostAddress: string, - hostPort: number + hostPort: number, ): Promise { - const url = `http://${hostAddress}:${hostPort}/message`; + const url = `${this.getProtocol(hostAddress)}://${this.formatHost(hostAddress)}:${hostPort}/message`; try { const response = await got.post(url, { @@ -29,6 +47,9 @@ export class RemoteTransport { timeout: { request: this.options.timeout }, retry: { limit: this.options.retries }, throwHttpErrors: false, + ...(this.options.useHttps && { + https: { rejectUnauthorized: this.options.rejectUnauthorized }, + }), }); if (response.statusCode === 200 || response.statusCode === 201) { @@ -36,7 +57,7 @@ export class RemoteTransport { messageId: message.id, delivered: true, deliveredAt: new Date().toISOString(), - via: 'remote', + via: "remote", }; } @@ -44,34 +65,36 @@ export class RemoteTransport { messageId: message.id, delivered: false, error: `HTTP ${response.statusCode}: ${response.body}`, - via: 'remote', + via: "remote", }; } catch (err: any) { return { messageId: message.id, delivered: false, - error: err.message || 'Connection failed', - via: 'remote', + error: err.message || "Connection failed", + via: "remote", }; } } async deliverToAgent( message: Message, - agentAddress: string + agentAddress: string, ): Promise { - const parts = agentAddress.split('@'); + const parts = agentAddress.split("@"); if (parts.length !== 2) { return { messageId: message.id, delivered: false, - error: 'Invalid agent address format. Expected: agentId@host:port', - via: 'remote', + error: "Invalid agent address format. Expected: agentId@host:port", + via: "remote", }; } const [, hostPart] = parts; - const [host, portStr] = hostPart.split(':'); + const lastColon = hostPart.lastIndexOf(":"); + const host = lastColon > 0 ? hostPart.slice(0, lastColon) : hostPart; + const portStr = lastColon > 0 ? hostPart.slice(lastColon + 1) : undefined; const port = portStr ? parseInt(portStr, 10) : 9876; return this.deliver(message, host, port); @@ -79,7 +102,7 @@ export class RemoteTransport { async broadcast( message: Message, - hosts: Array<{ address: string; port: number }> + hosts: Array<{ address: string; port: number }>, ): Promise> { const results = new Map(); @@ -87,20 +110,23 @@ export class RemoteTransport { hosts.map(async ({ address, port }) => { const result = await this.deliver(message, address, port); results.set(`${address}:${port}`, result); - }) + }), ); return results; } async ping(hostAddress: string, hostPort: number): Promise { - const url = `http://${hostAddress}:${hostPort}/health`; + const url = `${this.getProtocol(hostAddress)}://${this.formatHost(hostAddress)}:${hostPort}/health`; try { const response = await got.get(url, { timeout: { request: 5000 }, retry: { limit: 0 }, throwHttpErrors: false, + ...(this.options.useHttps && { + https: { rejectUnauthorized: this.options.rejectUnauthorized }, + }), }); return response.statusCode === 200; diff --git a/src/providers/bitbucket.ts b/src/providers/bitbucket.ts index 436760c1..b678c812 100644 --- a/src/providers/bitbucket.ts +++ b/src/providers/bitbucket.ts @@ -1,36 +1,41 @@ -import { execSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; -import { join, basename } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import type { GitProviderAdapter, CloneOptions } from './base.js'; -import { parseShorthand } from './base.js'; -import type { GitProvider, CloneResult } from '../core/types.js'; -import { discoverSkills } from '../core/skills.js'; +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { GitProviderAdapter, CloneOptions } from "./base.js"; +import { parseShorthand } from "./base.js"; +import type { GitProvider, CloneResult } from "../core/types.js"; +import { discoverSkills } from "../core/skills.js"; export class BitbucketProvider implements GitProviderAdapter { - readonly type: GitProvider = 'bitbucket'; - readonly name = 'Bitbucket'; - readonly baseUrl = 'https://bitbucket.org'; - - parseSource(source: string): { owner: string; repo: string; subpath?: string } | null { - - if (source.startsWith('https://bitbucket.org/')) { - const path = source.replace('https://bitbucket.org/', '').replace(/\.git$/, ''); + readonly type: GitProvider = "bitbucket"; + readonly name = "Bitbucket"; + readonly baseUrl = "https://bitbucket.org"; + + parseSource( + source: string, + ): { owner: string; repo: string; subpath?: string } | null { + if (source.startsWith("https://bitbucket.org/")) { + const path = source + .replace("https://bitbucket.org/", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('git@bitbucket.org:')) { - const path = source.replace('git@bitbucket.org:', '').replace(/\.git$/, ''); + if (source.startsWith("git@bitbucket.org:")) { + const path = source + .replace("git@bitbucket.org:", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('bitbucket:')) { - return parseShorthand(source.replace('bitbucket:', '')); + if (source.startsWith("bitbucket:")) { + return parseShorthand(source.replace("bitbucket:", "")); } - if (source.startsWith('bitbucket.org/')) { - return parseShorthand(source.replace('bitbucket.org/', '')); + if (source.startsWith("bitbucket.org/")) { + return parseShorthand(source.replace("bitbucket.org/", "")); } return null; @@ -38,10 +43,10 @@ export class BitbucketProvider implements GitProviderAdapter { matches(source: string): boolean { return ( - source.startsWith('https://bitbucket.org/') || - source.startsWith('git@bitbucket.org:') || - source.startsWith('bitbucket:') || - source.startsWith('bitbucket.org/') + source.startsWith("https://bitbucket.org/") || + source.startsWith("git@bitbucket.org:") || + source.startsWith("bitbucket:") || + source.startsWith("bitbucket.org/") ); } @@ -53,31 +58,36 @@ export class BitbucketProvider implements GitProviderAdapter { return `git@bitbucket.org:${owner}/${repo}.git`; } - async clone(source: string, _targetDir: string, options: CloneOptions = {}): Promise { + async clone( + source: string, + _targetDir: string, + options: CloneOptions = {}, + ): Promise { const parsed = this.parseSource(source); if (!parsed) { return { success: false, error: `Invalid Bitbucket source: ${source}` }; } const { owner, repo, subpath } = parsed; - const cloneUrl = options.ssh ? this.getSshUrl(owner, repo) : this.getCloneUrl(owner, repo); + const cloneUrl = options.ssh + ? this.getSshUrl(owner, repo) + : this.getCloneUrl(owner, repo); const tempDir = join(tmpdir(), `skillkit-${randomUUID()}`); try { - - const args = ['clone']; + const args = ["clone"]; if (options.depth) { - args.push('--depth', String(options.depth)); + args.push("--depth", String(options.depth)); } if (options.branch) { - args.push('--branch', options.branch); + args.push("--branch", options.branch); } args.push(cloneUrl, tempDir); - execSync(`git ${args.join(' ')}`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', + execFileSync("git", args, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", }); const searchDir = subpath ? join(tempDir, subpath) : tempDir; @@ -85,17 +95,16 @@ export class BitbucketProvider implements GitProviderAdapter { return { success: true, - path: searchDir, - tempRoot: tempDir, - skills: skills.map(s => s.name), - discoveredSkills: skills.map(s => ({ + path: searchDir, + tempRoot: tempDir, + skills: skills.map((s) => s.name), + discoveredSkills: skills.map((s) => ({ name: s.name, dirName: basename(s.path), path: s.path, })), }; } catch (error) { - if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/src/providers/github.ts b/src/providers/github.ts index 0b7651c8..62c9d755 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -1,31 +1,34 @@ -import { execSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; -import { join, basename } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import type { GitProviderAdapter, CloneOptions } from './base.js'; -import { parseShorthand, isGitUrl } from './base.js'; -import type { GitProvider, CloneResult } from '../core/types.js'; -import { discoverSkills } from '../core/skills.js'; +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { GitProviderAdapter, CloneOptions } from "./base.js"; +import { parseShorthand, isGitUrl } from "./base.js"; +import type { GitProvider, CloneResult } from "../core/types.js"; +import { discoverSkills } from "../core/skills.js"; export class GitHubProvider implements GitProviderAdapter { - readonly type: GitProvider = 'github'; - readonly name = 'GitHub'; - readonly baseUrl = 'https://github.com'; - - parseSource(source: string): { owner: string; repo: string; subpath?: string } | null { - - if (source.startsWith('https://github.com/')) { - const path = source.replace('https://github.com/', '').replace(/\.git$/, ''); + readonly type: GitProvider = "github"; + readonly name = "GitHub"; + readonly baseUrl = "https://github.com"; + + parseSource( + source: string, + ): { owner: string; repo: string; subpath?: string } | null { + if (source.startsWith("https://github.com/")) { + const path = source + .replace("https://github.com/", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('git@github.com:')) { - const path = source.replace('git@github.com:', '').replace(/\.git$/, ''); + if (source.startsWith("git@github.com:")) { + const path = source.replace("git@github.com:", "").replace(/\.git$/, ""); return parseShorthand(path); } - if (!isGitUrl(source) && !source.includes(':')) { + if (!isGitUrl(source) && !source.includes(":")) { return parseShorthand(source); } @@ -34,10 +37,9 @@ export class GitHubProvider implements GitProviderAdapter { matches(source: string): boolean { return ( - source.startsWith('https://github.com/') || - source.startsWith('git@github.com:') || - - (!isGitUrl(source) && !source.includes(':') && source.includes('/')) + source.startsWith("https://github.com/") || + source.startsWith("git@github.com:") || + (!isGitUrl(source) && !source.includes(":") && source.includes("/")) ); } @@ -49,31 +51,36 @@ export class GitHubProvider implements GitProviderAdapter { return `git@github.com:${owner}/${repo}.git`; } - async clone(source: string, _targetDir: string, options: CloneOptions = {}): Promise { + async clone( + source: string, + _targetDir: string, + options: CloneOptions = {}, + ): Promise { const parsed = this.parseSource(source); if (!parsed) { return { success: false, error: `Invalid GitHub source: ${source}` }; } const { owner, repo, subpath } = parsed; - const cloneUrl = options.ssh ? this.getSshUrl(owner, repo) : this.getCloneUrl(owner, repo); + const cloneUrl = options.ssh + ? this.getSshUrl(owner, repo) + : this.getCloneUrl(owner, repo); const tempDir = join(tmpdir(), `skillkit-${randomUUID()}`); try { - - const args = ['clone']; + const args = ["clone"]; if (options.depth) { - args.push('--depth', String(options.depth)); + args.push("--depth", String(options.depth)); } if (options.branch) { - args.push('--branch', options.branch); + args.push("--branch", options.branch); } args.push(cloneUrl, tempDir); - execSync(`git ${args.join(' ')}`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', + execFileSync("git", args, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", }); const searchDir = subpath ? join(tempDir, subpath) : tempDir; @@ -81,17 +88,16 @@ export class GitHubProvider implements GitProviderAdapter { return { success: true, - path: searchDir, - tempRoot: tempDir, - skills: skills.map(s => s.name), - discoveredSkills: skills.map(s => ({ + path: searchDir, + tempRoot: tempDir, + skills: skills.map((s) => s.name), + discoveredSkills: skills.map((s) => ({ name: s.name, dirName: basename(s.path), path: s.path, })), }; } catch (error) { - if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/src/providers/gitlab.ts b/src/providers/gitlab.ts index 735ac496..ea0bf195 100644 --- a/src/providers/gitlab.ts +++ b/src/providers/gitlab.ts @@ -1,36 +1,39 @@ -import { execSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; -import { join, basename } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import type { GitProviderAdapter, CloneOptions } from './base.js'; -import { parseShorthand } from './base.js'; -import type { GitProvider, CloneResult } from '../core/types.js'; -import { discoverSkills } from '../core/skills.js'; +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { GitProviderAdapter, CloneOptions } from "./base.js"; +import { parseShorthand } from "./base.js"; +import type { GitProvider, CloneResult } from "../core/types.js"; +import { discoverSkills } from "../core/skills.js"; export class GitLabProvider implements GitProviderAdapter { - readonly type: GitProvider = 'gitlab'; - readonly name = 'GitLab'; - readonly baseUrl = 'https://gitlab.com'; - - parseSource(source: string): { owner: string; repo: string; subpath?: string } | null { - - if (source.startsWith('https://gitlab.com/')) { - const path = source.replace('https://gitlab.com/', '').replace(/\.git$/, ''); + readonly type: GitProvider = "gitlab"; + readonly name = "GitLab"; + readonly baseUrl = "https://gitlab.com"; + + parseSource( + source: string, + ): { owner: string; repo: string; subpath?: string } | null { + if (source.startsWith("https://gitlab.com/")) { + const path = source + .replace("https://gitlab.com/", "") + .replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('git@gitlab.com:')) { - const path = source.replace('git@gitlab.com:', '').replace(/\.git$/, ''); + if (source.startsWith("git@gitlab.com:")) { + const path = source.replace("git@gitlab.com:", "").replace(/\.git$/, ""); return parseShorthand(path); } - if (source.startsWith('gitlab:')) { - return parseShorthand(source.replace('gitlab:', '')); + if (source.startsWith("gitlab:")) { + return parseShorthand(source.replace("gitlab:", "")); } - if (source.startsWith('gitlab.com/')) { - return parseShorthand(source.replace('gitlab.com/', '')); + if (source.startsWith("gitlab.com/")) { + return parseShorthand(source.replace("gitlab.com/", "")); } return null; @@ -38,10 +41,10 @@ export class GitLabProvider implements GitProviderAdapter { matches(source: string): boolean { return ( - source.startsWith('https://gitlab.com/') || - source.startsWith('git@gitlab.com:') || - source.startsWith('gitlab:') || - source.startsWith('gitlab.com/') + source.startsWith("https://gitlab.com/") || + source.startsWith("git@gitlab.com:") || + source.startsWith("gitlab:") || + source.startsWith("gitlab.com/") ); } @@ -53,31 +56,36 @@ export class GitLabProvider implements GitProviderAdapter { return `git@gitlab.com:${owner}/${repo}.git`; } - async clone(source: string, _targetDir: string, options: CloneOptions = {}): Promise { + async clone( + source: string, + _targetDir: string, + options: CloneOptions = {}, + ): Promise { const parsed = this.parseSource(source); if (!parsed) { return { success: false, error: `Invalid GitLab source: ${source}` }; } const { owner, repo, subpath } = parsed; - const cloneUrl = options.ssh ? this.getSshUrl(owner, repo) : this.getCloneUrl(owner, repo); + const cloneUrl = options.ssh + ? this.getSshUrl(owner, repo) + : this.getCloneUrl(owner, repo); const tempDir = join(tmpdir(), `skillkit-${randomUUID()}`); try { - - const args = ['clone']; + const args = ["clone"]; if (options.depth) { - args.push('--depth', String(options.depth)); + args.push("--depth", String(options.depth)); } if (options.branch) { - args.push('--branch', options.branch); + args.push("--branch", options.branch); } args.push(cloneUrl, tempDir); - execSync(`git ${args.join(' ')}`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', + execFileSync("git", args, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", }); const searchDir = subpath ? join(tempDir, subpath) : tempDir; @@ -85,17 +93,16 @@ export class GitLabProvider implements GitProviderAdapter { return { success: true, - path: searchDir, - tempRoot: tempDir, - skills: skills.map(s => s.name), - discoveredSkills: skills.map(s => ({ + path: searchDir, + tempRoot: tempDir, + skills: skills.map((s) => s.name), + discoveredSkills: skills.map((s) => ({ name: s.name, dirName: basename(s.path), path: s.path, })), }; } catch (error) { - if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); }