skill-base
Advanced tools
| function up(db) { | ||
| db.exec(` | ||
| CREATE TABLE IF NOT EXISTS collections ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| name TEXT NOT NULL UNIQUE, | ||
| description TEXT, | ||
| sort_order INTEGER NOT NULL DEFAULT 0, | ||
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||
| updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||
| created_by INTEGER NOT NULL, | ||
| updated_by INTEGER NOT NULL, | ||
| FOREIGN KEY (created_by) REFERENCES users(id), | ||
| FOREIGN KEY (updated_by) REFERENCES users(id) | ||
| ); | ||
| CREATE TABLE IF NOT EXISTS collection_skills ( | ||
| collection_id INTEGER NOT NULL, | ||
| skill_id TEXT NOT NULL, | ||
| sort_order INTEGER NOT NULL DEFAULT 0, | ||
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||
| created_by INTEGER NOT NULL, | ||
| PRIMARY KEY (collection_id, skill_id), | ||
| FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE, | ||
| FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, | ||
| FOREIGN KEY (created_by) REFERENCES users(id) | ||
| ); | ||
| CREATE INDEX IF NOT EXISTS idx_collections_sort ON collections(sort_order, name); | ||
| CREATE INDEX IF NOT EXISTS idx_collection_skills_skill ON collection_skills(skill_id); | ||
| CREATE INDEX IF NOT EXISTS idx_collection_skills_order ON collection_skills(collection_id, sort_order); | ||
| `); | ||
| } | ||
| module.exports = { | ||
| version: '007-collections', | ||
| up | ||
| }; |
| function addColumnIfMissing(db, sql) { | ||
| try { | ||
| db.exec(sql); | ||
| } catch (error) { | ||
| if (!String(error && error.message ? error.message : error).includes('duplicate column name')) { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| function up(db) { | ||
| addColumnIfMissing(db, 'ALTER TABLE collections ADD COLUMN download_count INTEGER NOT NULL DEFAULT 0'); | ||
| db.exec(` | ||
| UPDATE collections | ||
| SET download_count = COALESCE(download_count, 0); | ||
| `); | ||
| } | ||
| module.exports = { | ||
| version: '008-collection-download-count', | ||
| up | ||
| }; |
| function addColumnIfMissing(db, sql) { | ||
| try { | ||
| db.exec(sql); | ||
| } catch (error) { | ||
| if (!String(error && error.message ? error.message : error).includes('duplicate column name')) { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| function up(db) { | ||
| addColumnIfMissing(db, 'ALTER TABLE collections ADD COLUMN slug TEXT'); | ||
| db.exec(` | ||
| UPDATE collections | ||
| SET slug = 'collection-' || id | ||
| WHERE slug IS NULL OR TRIM(slug) = ''; | ||
| `); | ||
| db.exec(` | ||
| CREATE UNIQUE INDEX IF NOT EXISTS idx_collections_slug ON collections(slug); | ||
| `); | ||
| } | ||
| module.exports = { | ||
| version: '009-collection-slug', | ||
| up | ||
| }; |
| const db = require('../database'); | ||
| const modelCache = require('../utils/model-cache'); | ||
| const MAX_COLLECTION_SKILLS = 10; | ||
| const MAX_COLLECTION_DESCRIPTION_LENGTH = 120; | ||
| const MIN_COLLECTION_SLUG_LENGTH = 2; | ||
| const MAX_COLLECTION_SLUG_LENGTH = 64; | ||
| const COLLECTION_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/; | ||
| function normalizeName(name) { | ||
| return String(name || '').trim(); | ||
| } | ||
| function normalizeDescription(description) { | ||
| if (description === undefined || description === null) return ''; | ||
| return String(description).trim(); | ||
| } | ||
| function isDescriptionTooLong(description) { | ||
| return normalizeDescription(description).length > MAX_COLLECTION_DESCRIPTION_LENGTH; | ||
| } | ||
| function normalizeSortOrder(sortOrder) { | ||
| const n = Number(sortOrder); | ||
| return Number.isFinite(n) ? Math.trunc(n) : 0; | ||
| } | ||
| function normalizeSlug(slug) { | ||
| return String(slug || '').trim().toLowerCase(); | ||
| } | ||
| function isValidSlug(slug) { | ||
| const normalized = normalizeSlug(slug); | ||
| return ( | ||
| normalized.length >= MIN_COLLECTION_SLUG_LENGTH | ||
| && normalized.length <= MAX_COLLECTION_SLUG_LENGTH | ||
| && COLLECTION_SLUG_PATTERN.test(normalized) | ||
| ); | ||
| } | ||
| function isSlugConflictError(error) { | ||
| const message = String(error && error.message ? error.message : error); | ||
| return message.includes('UNIQUE constraint failed') && message.includes('collections.slug'); | ||
| } | ||
| function getCollectionById(id) { | ||
| return db.prepare(` | ||
| SELECT | ||
| c.id, | ||
| c.name, | ||
| c.slug, | ||
| c.description, | ||
| c.sort_order, | ||
| c.download_count, | ||
| c.created_at, | ||
| c.updated_at, | ||
| c.created_by, | ||
| c.updated_by, | ||
| u.username AS creator_username, | ||
| u.name AS creator_name, | ||
| COUNT(cs.skill_id) AS skill_count | ||
| FROM collections c | ||
| LEFT JOIN collection_skills cs ON cs.collection_id = c.id | ||
| LEFT JOIN users u ON c.created_by = u.id | ||
| WHERE c.id = ? | ||
| GROUP BY c.id | ||
| `).get(id); | ||
| } | ||
| function invalidateSkillRefs(skillIds) { | ||
| for (const skillId of skillIds || []) { | ||
| modelCache.invalidateSkill(skillId); | ||
| } | ||
| } | ||
| function collectionBrowseVisibilityPredicate(viewer) { | ||
| if (viewer) { | ||
| return { | ||
| join: 'LEFT JOIN skill_collaborators sc_view ON s.id = sc_view.skill_id AND sc_view.user_id = ?', | ||
| where: "AND (s.visibility = 'public' OR sc_view.user_id IS NOT NULL)", | ||
| params: [viewer.id] | ||
| }; | ||
| } | ||
| return { join: '', where: "AND s.visibility = 'public'", params: [] }; | ||
| } | ||
| const CollectionModel = { | ||
| create({ name, slug, description, sortOrder, actorId }) { | ||
| const result = db.prepare(` | ||
| INSERT INTO collections (name, slug, description, sort_order, created_by, updated_by) | ||
| VALUES (?, ?, ?, ?, ?, ?) | ||
| `).run( | ||
| normalizeName(name), | ||
| normalizeSlug(slug), | ||
| normalizeDescription(description), | ||
| normalizeSortOrder(sortOrder), | ||
| actorId, | ||
| actorId | ||
| ); | ||
| return getCollectionById(result.lastInsertRowid); | ||
| }, | ||
| update(id, { name, slug, description, sortOrder, actorId }) { | ||
| const fields = []; | ||
| const values = []; | ||
| if (name !== undefined) { | ||
| fields.push('name = ?'); | ||
| values.push(normalizeName(name)); | ||
| } | ||
| if (slug !== undefined) { | ||
| fields.push('slug = ?'); | ||
| values.push(normalizeSlug(slug)); | ||
| } | ||
| if (description !== undefined) { | ||
| fields.push('description = ?'); | ||
| values.push(normalizeDescription(description)); | ||
| } | ||
| if (sortOrder !== undefined) { | ||
| fields.push('sort_order = ?'); | ||
| values.push(normalizeSortOrder(sortOrder)); | ||
| } | ||
| if (fields.length === 0) return getCollectionById(id); | ||
| fields.push('updated_at = CURRENT_TIMESTAMP'); | ||
| fields.push('updated_by = ?'); | ||
| values.push(actorId, id); | ||
| db.prepare(`UPDATE collections SET ${fields.join(', ')} WHERE id = ?`).run(...values); | ||
| return getCollectionById(id); | ||
| }, | ||
| delete(id) { | ||
| const affectedSkills = db.prepare(` | ||
| SELECT skill_id | ||
| FROM collection_skills | ||
| WHERE collection_id = ? | ||
| `).all(id).map((row) => row.skill_id); | ||
| const result = db.prepare('DELETE FROM collections WHERE id = ?').run(id); | ||
| invalidateSkillRefs(affectedSkills); | ||
| return result.changes > 0; | ||
| }, | ||
| exists(id) { | ||
| const row = db.prepare('SELECT 1 FROM collections WHERE id = ?').get(id); | ||
| return !!row; | ||
| }, | ||
| findById(id) { | ||
| return getCollectionById(id); | ||
| }, | ||
| findBySlug(slug) { | ||
| const normalized = normalizeSlug(slug); | ||
| if (!normalized) return null; | ||
| const row = db.prepare('SELECT id FROM collections WHERE slug = ?').get(normalized); | ||
| if (!row) return null; | ||
| return getCollectionById(row.id); | ||
| }, | ||
| resolveRef(ref) { | ||
| const raw = String(ref || '').trim(); | ||
| if (!raw) return null; | ||
| const asId = Number(raw); | ||
| if (Number.isInteger(asId) && asId > 0) { | ||
| return this.findById(asId); | ||
| } | ||
| return this.findBySlug(raw); | ||
| }, | ||
| countVisibleCollectionSkills(collectionId, viewer) { | ||
| const visibility = collectionBrowseVisibilityPredicate(viewer); | ||
| const row = db.prepare(` | ||
| SELECT COUNT(*) AS count | ||
| FROM collection_skills cs | ||
| JOIN skills s ON s.id = cs.skill_id | ||
| ${visibility.join} | ||
| WHERE cs.collection_id = ? | ||
| ${visibility.where} | ||
| `).get(...visibility.params, collectionId); | ||
| return row?.count || 0; | ||
| }, | ||
| listAllWithUsage(viewer) { | ||
| const collections = db.prepare(` | ||
| SELECT | ||
| c.id, | ||
| c.name, | ||
| c.slug, | ||
| c.description, | ||
| c.sort_order, | ||
| c.download_count, | ||
| c.created_at, | ||
| c.updated_at | ||
| FROM collections c | ||
| ORDER BY c.sort_order ASC, c.name ASC | ||
| `).all(); | ||
| return collections.map((collection) => ({ | ||
| ...collection, | ||
| skill_count: this.countVisibleCollectionSkills(collection.id, viewer) | ||
| })); | ||
| }, | ||
| listSkillCollections(skillId) { | ||
| return db.prepare(` | ||
| SELECT c.id, c.name | ||
| FROM collection_skills cs | ||
| JOIN collections c ON c.id = cs.collection_id | ||
| WHERE cs.skill_id = ? | ||
| ORDER BY c.sort_order ASC, c.name ASC | ||
| `).all(skillId); | ||
| }, | ||
| listCollectionSkills(collectionId, viewer) { | ||
| const visibility = collectionBrowseVisibilityPredicate(viewer); | ||
| return db.prepare(` | ||
| SELECT s.*, u.username as owner_username, u.name as owner_name | ||
| FROM collection_skills cs | ||
| JOIN skills s ON s.id = cs.skill_id | ||
| LEFT JOIN users u ON s.owner_id = u.id | ||
| ${visibility.join} | ||
| WHERE cs.collection_id = ? | ||
| ${visibility.where} | ||
| ORDER BY cs.sort_order ASC, s.updated_at DESC | ||
| `).all(...visibility.params, collectionId); | ||
| }, | ||
| listAllCollectionSkills(collectionId) { | ||
| return db.prepare(` | ||
| SELECT s.*, u.username as owner_username, u.name as owner_name | ||
| FROM collection_skills cs | ||
| JOIN skills s ON s.id = cs.skill_id | ||
| LEFT JOIN users u ON s.owner_id = u.id | ||
| WHERE cs.collection_id = ? | ||
| ORDER BY cs.sort_order ASC, s.updated_at DESC | ||
| `).all(collectionId); | ||
| }, | ||
| incrementDownloadCount(id) { | ||
| db.prepare(` | ||
| UPDATE collections | ||
| SET download_count = COALESCE(download_count, 0) + 1 | ||
| WHERE id = ? | ||
| `).run(id); | ||
| }, | ||
| replaceCollectionSkills(collectionId, skillIds, actorId) { | ||
| const uniqueSkillIds = [...new Set((skillIds || []).map((id) => String(id).trim()).filter(Boolean))]; | ||
| if (uniqueSkillIds.length > MAX_COLLECTION_SKILLS) { | ||
| throw new Error(`A collection can contain at most ${MAX_COLLECTION_SKILLS} skills`); | ||
| } | ||
| return db.transaction(() => { | ||
| if (!this.exists(collectionId)) { | ||
| throw new Error('Collection not found'); | ||
| } | ||
| if (uniqueSkillIds.length > 0) { | ||
| const placeholders = uniqueSkillIds.map(() => '?').join(', '); | ||
| const rows = db.prepare(`SELECT id FROM skills WHERE id IN (${placeholders})`).all(...uniqueSkillIds); | ||
| if (rows.length !== uniqueSkillIds.length) { | ||
| throw new Error('One or more skills do not exist'); | ||
| } | ||
| } | ||
| const previousSkillIds = db.prepare(` | ||
| SELECT skill_id | ||
| FROM collection_skills | ||
| WHERE collection_id = ? | ||
| `).all(collectionId).map((row) => row.skill_id); | ||
| db.prepare('DELETE FROM collection_skills WHERE collection_id = ?').run(collectionId); | ||
| const insert = db.prepare(` | ||
| INSERT INTO collection_skills (collection_id, skill_id, sort_order, created_by) | ||
| VALUES (?, ?, ?, ?) | ||
| `); | ||
| uniqueSkillIds.forEach((skillId, index) => { | ||
| insert.run(collectionId, skillId, index, actorId); | ||
| }); | ||
| invalidateSkillRefs([...new Set([...previousSkillIds, ...uniqueSkillIds])]); | ||
| return this.listAllCollectionSkills(collectionId); | ||
| })(); | ||
| } | ||
| }; | ||
| module.exports = CollectionModel; | ||
| module.exports.MAX_COLLECTION_SKILLS = MAX_COLLECTION_SKILLS; | ||
| module.exports.MAX_COLLECTION_DESCRIPTION_LENGTH = MAX_COLLECTION_DESCRIPTION_LENGTH; | ||
| module.exports.MIN_COLLECTION_SLUG_LENGTH = MIN_COLLECTION_SLUG_LENGTH; | ||
| module.exports.MAX_COLLECTION_SLUG_LENGTH = MAX_COLLECTION_SLUG_LENGTH; | ||
| module.exports.isDescriptionTooLong = isDescriptionTooLong; | ||
| module.exports.normalizeSlug = normalizeSlug; | ||
| module.exports.isValidSlug = isValidSlug; | ||
| module.exports.isSlugConflictError = isSlugConflictError; |
| const CollectionModel = require('../models/collection'); | ||
| const { | ||
| isDescriptionTooLong, | ||
| isValidSlug, | ||
| isSlugConflictError | ||
| } = require('../models/collection'); | ||
| const TagModel = require('../models/tag'); | ||
| const { buildCollectionZipBuffer, incrementDownloadCounts } = require('../utils/collection-bundle'); | ||
| function formatSkill(skill) { | ||
| return { | ||
| id: skill.id, | ||
| name: skill.name, | ||
| description: skill.description, | ||
| latest_version: skill.latest_version, | ||
| favorite_count: skill.favorite_count || 0, | ||
| download_count: skill.download_count || 0, | ||
| visibility: skill.visibility || 'public', | ||
| tags: TagModel.listSkillTags(skill.id), | ||
| collections: CollectionModel.listSkillCollections(skill.id), | ||
| owner: { | ||
| id: skill.owner_id, | ||
| username: skill.owner_username, | ||
| name: skill.owner_name | ||
| }, | ||
| created_at: skill.created_at, | ||
| updated_at: skill.updated_at | ||
| }; | ||
| } | ||
| function formatCollection(collection) { | ||
| if (!collection) return collection; | ||
| const { creator_username, creator_name, created_by, ...rest } = collection; | ||
| const formatted = { | ||
| ...rest, | ||
| download_count: rest.download_count || 0 | ||
| }; | ||
| if (created_by) { | ||
| formatted.created_by = { | ||
| id: created_by, | ||
| username: creator_username || null, | ||
| name: creator_name || null | ||
| }; | ||
| } | ||
| return formatted; | ||
| } | ||
| function assertSlug(reply, slug, { required = false } = {}) { | ||
| if (slug === undefined || slug === null || slug === '') { | ||
| if (required) { | ||
| reply.code(400).send({ detail: 'Collection slug is required' }); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| if (!isValidSlug(slug)) { | ||
| reply.code(400).send({ | ||
| detail: 'Collection slug must start with a letter and contain only lowercase letters, numbers, and hyphens' | ||
| }); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| function assertMutableFields(reply, body, requireName) { | ||
| const name = body?.name; | ||
| if (requireName && !String(name || '').trim()) { | ||
| reply.code(400).send({ detail: 'Collection name is required' }); | ||
| return false; | ||
| } | ||
| if (name !== undefined && !String(name || '').trim()) { | ||
| reply.code(400).send({ detail: 'Collection name is required' }); | ||
| return false; | ||
| } | ||
| const slug = body?.slug; | ||
| if (requireName && (slug === undefined || slug === null || !String(slug).trim())) { | ||
| reply.code(400).send({ detail: 'Collection slug is required' }); | ||
| return false; | ||
| } | ||
| if (slug !== undefined && slug !== null) { | ||
| if (!String(slug).trim()) { | ||
| reply.code(400).send({ detail: 'Collection slug is required' }); | ||
| return false; | ||
| } | ||
| if (!assertSlug(reply, slug)) { | ||
| return false; | ||
| } | ||
| } | ||
| if (body?.sort_order !== undefined && !Number.isFinite(Number(body.sort_order))) { | ||
| reply.code(400).send({ detail: 'sort_order must be a number' }); | ||
| return false; | ||
| } | ||
| if (body?.description !== undefined && isDescriptionTooLong(body.description)) { | ||
| reply.code(400).send({ detail: 'Collection description is too long' }); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| function mutateCollection(reply, action) { | ||
| try { | ||
| return action(); | ||
| } catch (error) { | ||
| if (isSlugConflictError(error)) { | ||
| reply.code(409).send({ detail: 'Collection slug already exists' }); | ||
| return null; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| async function collectionsRoutes(fastify) { | ||
| fastify.get('/', { | ||
| preHandler: [fastify.optionalAuth] | ||
| }, async (request) => { | ||
| return { collections: CollectionModel.listAllWithUsage(request.user).map(formatCollection) }; | ||
| }); | ||
| fastify.get('/:collection_ref', { | ||
| preHandler: [fastify.optionalAuth] | ||
| }, async (request, reply) => { | ||
| const collection = CollectionModel.resolveRef(request.params.collection_ref); | ||
| if (!collection) { | ||
| return reply.code(404).send({ detail: 'Collection not found' }); | ||
| } | ||
| const includePrivate = request.query?.include_private === '1' && request.user?.role === 'admin'; | ||
| const rawSkills = includePrivate | ||
| ? CollectionModel.listAllCollectionSkills(collection.id) | ||
| : CollectionModel.listCollectionSkills(collection.id, request.user); | ||
| const skills = rawSkills.map(formatSkill); | ||
| const formattedCollection = formatCollection(collection); | ||
| formattedCollection.skill_count = skills.length; | ||
| return { collection: formattedCollection, skills, total: skills.length }; | ||
| }); | ||
| fastify.get('/:collection_ref/download', { | ||
| preHandler: [fastify.optionalAuth] | ||
| }, async (request, reply) => { | ||
| const collection = CollectionModel.resolveRef(request.params.collection_ref); | ||
| if (!collection) { | ||
| return reply.code(404).send({ detail: 'Collection not found' }); | ||
| } | ||
| const skills = CollectionModel.listCollectionSkills(collection.id, request.user); | ||
| const { buffer, fileName, packagedSkills } = buildCollectionZipBuffer(collection, skills); | ||
| if (packagedSkills.length === 0) { | ||
| return reply.code(404).send({ detail: 'No downloadable skills in this collection' }); | ||
| } | ||
| incrementDownloadCounts(packagedSkills); | ||
| CollectionModel.incrementDownloadCount(collection.id); | ||
| reply.header('Content-Type', 'application/zip'); | ||
| reply.header('Content-Disposition', `attachment; filename="${fileName}"`); | ||
| return reply.send(buffer); | ||
| }); | ||
| fastify.post('/', { | ||
| preHandler: [fastify.requireAdmin] | ||
| }, async (request, reply) => { | ||
| if (!assertMutableFields(reply, request.body, true)) return reply; | ||
| const collection = mutateCollection(reply, () => CollectionModel.create({ | ||
| name: request.body.name, | ||
| slug: request.body.slug, | ||
| description: request.body.description, | ||
| sortOrder: request.body.sort_order, | ||
| actorId: request.user.id | ||
| })); | ||
| if (!collection) return reply; | ||
| return reply.code(201).send({ ok: true, collection: formatCollection(collection) }); | ||
| }); | ||
| fastify.patch('/:collection_ref', { | ||
| preHandler: [fastify.requireAdmin] | ||
| }, async (request, reply) => { | ||
| const collection = CollectionModel.resolveRef(request.params.collection_ref); | ||
| if (!collection) { | ||
| return reply.code(404).send({ detail: 'Collection not found' }); | ||
| } | ||
| if (!assertMutableFields(reply, request.body, false)) return reply; | ||
| const updated = mutateCollection(reply, () => CollectionModel.update(collection.id, { | ||
| name: request.body?.name, | ||
| slug: request.body?.slug, | ||
| description: request.body?.description, | ||
| sortOrder: request.body?.sort_order, | ||
| actorId: request.user.id | ||
| })); | ||
| if (!updated) return reply; | ||
| return { ok: true, collection: formatCollection(updated) }; | ||
| }); | ||
| fastify.delete('/:collection_ref', { | ||
| preHandler: [fastify.requireAdmin] | ||
| }, async (request, reply) => { | ||
| const collection = CollectionModel.resolveRef(request.params.collection_ref); | ||
| if (!collection) { | ||
| return reply.code(404).send({ detail: 'Collection not found' }); | ||
| } | ||
| const deleted = CollectionModel.delete(collection.id); | ||
| if (!deleted) { | ||
| return reply.code(404).send({ detail: 'Collection not found' }); | ||
| } | ||
| return { ok: true }; | ||
| }); | ||
| fastify.put('/:collection_ref/skills', { | ||
| preHandler: [fastify.requireAdmin] | ||
| }, async (request, reply) => { | ||
| const collection = CollectionModel.resolveRef(request.params.collection_ref); | ||
| const { skill_ids: skillIds } = request.body || {}; | ||
| if (!collection) { | ||
| return reply.code(404).send({ detail: 'Collection not found' }); | ||
| } | ||
| if (skillIds !== undefined && !Array.isArray(skillIds)) { | ||
| return reply.code(400).send({ detail: 'skill_ids must be an array' }); | ||
| } | ||
| try { | ||
| const skills = CollectionModel.replaceCollectionSkills(collection.id, skillIds || [], request.user.id).map(formatSkill); | ||
| return { ok: true, collection_id: collection.id, skills }; | ||
| } catch (error) { | ||
| const message = String(error && error.message ? error.message : error); | ||
| if (message.includes('Collection not found')) { | ||
| return reply.code(404).send({ detail: 'Collection not found' }); | ||
| } | ||
| if (message.includes('skills do not exist')) { | ||
| return reply.code(400).send({ detail: 'One or more skills do not exist' }); | ||
| } | ||
| if (message.includes('can contain at most')) { | ||
| return reply.code(400).send({ detail: message }); | ||
| } | ||
| throw error; | ||
| } | ||
| }); | ||
| } | ||
| module.exports = collectionsRoutes; |
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const AdmZip = require('adm-zip'); | ||
| const VersionModel = require('../models/version'); | ||
| const SkillModel = require('../models/skill'); | ||
| const { resolveZipPath } = require('./zip'); | ||
| const { isJunkZipPath } = require('./zip-sanitize'); | ||
| function buildCollectionFileName(collection) { | ||
| const slug = String(collection?.slug || '').trim().toLowerCase(); | ||
| if (slug) { | ||
| return `${slug}.zip`; | ||
| } | ||
| return `collection-${collection.id}.zip`; | ||
| } | ||
| function resolveExistingZipPath(skillId, versionRecord) { | ||
| const zipPath = resolveZipPath(versionRecord.zip_path, skillId, versionRecord.version); | ||
| if (fs.existsSync(zipPath)) { | ||
| return zipPath; | ||
| } | ||
| return null; | ||
| } | ||
| function normalizeEntryPath(entryName) { | ||
| return String(entryName || '').replace(/\\/g, '/').replace(/^\/+/, ''); | ||
| } | ||
| function stripSkillRootPrefix(entryPath, skillId) { | ||
| const normalized = normalizeEntryPath(entryPath); | ||
| if (normalized === skillId || normalized.startsWith(`${skillId}/`)) { | ||
| return normalized.slice(skillId.length).replace(/^\/+/, ''); | ||
| } | ||
| return normalized; | ||
| } | ||
| function addSkillZipToBundle(outZip, skillId, zipFilePath) { | ||
| const skillZip = new AdmZip(zipFilePath); | ||
| for (const entry of skillZip.getEntries()) { | ||
| if (entry.isDirectory) continue; | ||
| if (isJunkZipPath(entry.entryName)) continue; | ||
| const relative = stripSkillRootPrefix(entry.entryName, skillId); | ||
| if (!relative) continue; | ||
| outZip.addFile(`${skillId}/${relative}`, entry.getData()); | ||
| } | ||
| } | ||
| /** | ||
| * Build a collection zip buffer from visible skills (must have latest_version + zip on disk). | ||
| * Zip root contains one folder per skill: skill-id/SKILL.md ... | ||
| * @param {{ id: number, name: string }} collection | ||
| * @param {Array<{ id: string, latest_version?: string }>} skills | ||
| * @returns {{ buffer: Buffer, fileName: string, packagedSkills: Array<{ skillId: string, version: string }> }} | ||
| */ | ||
| function buildCollectionZipBuffer(collection, skills) { | ||
| const outZip = new AdmZip(); | ||
| const packagedSkills = []; | ||
| for (const skill of skills) { | ||
| const skillId = skill.id; | ||
| const version = skill.latest_version; | ||
| if (!version) continue; | ||
| const versionRecord = VersionModel.getLatest(skillId); | ||
| if (!versionRecord || versionRecord.version !== version) { | ||
| const exact = VersionModel.findByVersion(skillId, version); | ||
| if (!exact) continue; | ||
| const zipPath = resolveExistingZipPath(skillId, exact); | ||
| if (!zipPath) continue; | ||
| addSkillZipToBundle(outZip, skillId, zipPath); | ||
| packagedSkills.push({ skillId, version }); | ||
| continue; | ||
| } | ||
| const zipPath = resolveExistingZipPath(skillId, versionRecord); | ||
| if (!zipPath) continue; | ||
| addSkillZipToBundle(outZip, skillId, zipPath); | ||
| packagedSkills.push({ skillId, version }); | ||
| } | ||
| const fileName = buildCollectionFileName(collection); | ||
| return { buffer: outZip.toBuffer(), fileName, packagedSkills }; | ||
| } | ||
| function incrementDownloadCounts(packagedSkills) { | ||
| for (const { skillId, version } of packagedSkills) { | ||
| SkillModel.incrementDownloadCount(skillId); | ||
| VersionModel.incrementDownloadCount(skillId, version); | ||
| } | ||
| } | ||
| module.exports = { | ||
| buildCollectionZipBuffer, | ||
| buildCollectionFileName, | ||
| incrementDownloadCounts | ||
| }; |
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { spawn } = require('child_process'); | ||
| const PID_FILE = 'skill-base.pid'; | ||
| const LOG_FILE = 'skill-base.log'; | ||
| function getDefaultDataDir() { | ||
| return path.join(__dirname, '../../data'); | ||
| } | ||
| function resolveDataDir(explicitDataDir) { | ||
| return explicitDataDir ? path.resolve(explicitDataDir) : getDefaultDataDir(); | ||
| } | ||
| function getPidPath(dataDir) { | ||
| return path.join(dataDir, PID_FILE); | ||
| } | ||
| function getLogPath(dataDir) { | ||
| return path.join(dataDir, LOG_FILE); | ||
| } | ||
| function isProcessAlive(pid) { | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } catch (err) { | ||
| return err.code === 'EPERM'; | ||
| } | ||
| } | ||
| function readPid(dataDir) { | ||
| const file = getPidPath(dataDir); | ||
| if (!fs.existsSync(file)) return null; | ||
| const pid = parseInt(fs.readFileSync(file, 'utf8').trim(), 10); | ||
| return Number.isFinite(pid) && pid > 0 ? pid : null; | ||
| } | ||
| function removePidFile(dataDir) { | ||
| const file = getPidPath(dataDir); | ||
| if (fs.existsSync(file)) { | ||
| fs.unlinkSync(file); | ||
| } | ||
| } | ||
| function getDaemonStatus(dataDir) { | ||
| const pid = readPid(dataDir); | ||
| if (!pid) { | ||
| return { running: false, pid: null, stale: false }; | ||
| } | ||
| if (isProcessAlive(pid)) { | ||
| return { running: true, pid, stale: false }; | ||
| } | ||
| removePidFile(dataDir); | ||
| return { running: false, pid, stale: true }; | ||
| } | ||
| function sleepMs(ms) { | ||
| const end = Date.now() + ms; | ||
| while (Date.now() < end) { | ||
| // busy wait — CLI stop/status only | ||
| } | ||
| } | ||
| function stopDaemon(dataDir) { | ||
| const status = getDaemonStatus(dataDir); | ||
| if (!status.running) { | ||
| return { stopped: false, wasRunning: false, pid: status.pid, stale: status.stale }; | ||
| } | ||
| try { | ||
| process.kill(status.pid, 'SIGTERM'); | ||
| } catch (err) { | ||
| removePidFile(dataDir); | ||
| throw err; | ||
| } | ||
| const deadline = Date.now() + 5000; | ||
| while (Date.now() < deadline) { | ||
| if (!isProcessAlive(status.pid)) { | ||
| removePidFile(dataDir); | ||
| return { stopped: true, wasRunning: true, pid: status.pid, force: false }; | ||
| } | ||
| sleepMs(200); | ||
| } | ||
| try { | ||
| process.kill(status.pid, 'SIGKILL'); | ||
| } catch { | ||
| // already gone | ||
| } | ||
| removePidFile(dataDir); | ||
| return { stopped: true, wasRunning: true, pid: status.pid, force: true }; | ||
| } | ||
| function startDaemon({ entryScript, args, dataDir, env }) { | ||
| const status = getDaemonStatus(dataDir); | ||
| if (status.running) { | ||
| const err = new Error('ALREADY_RUNNING'); | ||
| err.pid = status.pid; | ||
| throw err; | ||
| } | ||
| if (!fs.existsSync(dataDir)) { | ||
| fs.mkdirSync(dataDir, { recursive: true }); | ||
| } | ||
| const logFile = getLogPath(dataDir); | ||
| const logFd = fs.openSync(logFile, 'a'); | ||
| const child = spawn(process.execPath, [entryScript, ...args], { | ||
| detached: true, | ||
| stdio: ['ignore', logFd, logFd], | ||
| env | ||
| }); | ||
| fs.closeSync(logFd); | ||
| child.unref(); | ||
| fs.writeFileSync(getPidPath(dataDir), String(child.pid)); | ||
| return { pid: child.pid, logFile }; | ||
| } | ||
| module.exports = { | ||
| PID_FILE, | ||
| LOG_FILE, | ||
| getDefaultDataDir, | ||
| resolveDataDir, | ||
| getPidPath, | ||
| getLogPath, | ||
| getDaemonStatus, | ||
| startDaemon, | ||
| stopDaemon | ||
| }; |
| const AdmZip = require('adm-zip'); | ||
| /** macOS zip 元数据目录,上传时应丢弃 */ | ||
| function isJunkZipPath(entryPath) { | ||
| const normalized = String(entryPath || '').replace(/\\/g, '/'); | ||
| return normalized.split('/').some((seg) => seg === '__MACOSX'); | ||
| } | ||
| /** | ||
| * 去掉 zip 中的 __MACOSX 条目;无垃圾条目时返回原 buffer。 | ||
| * @param {Buffer} buffer | ||
| * @returns {Buffer} | ||
| */ | ||
| function sanitizeZipBuffer(buffer) { | ||
| let zip; | ||
| try { | ||
| zip = new AdmZip(buffer); | ||
| } catch { | ||
| return buffer; | ||
| } | ||
| const entries = zip.getEntries(); | ||
| const keep = entries.filter((e) => !isJunkZipPath(e.entryName)); | ||
| if (keep.length === entries.length) { | ||
| return buffer; | ||
| } | ||
| const out = new AdmZip(); | ||
| for (const entry of keep) { | ||
| if (entry.isDirectory) continue; | ||
| out.addFile(entry.entryName.replace(/\\/g, '/'), entry.getData()); | ||
| } | ||
| return out.toBuffer(); | ||
| } | ||
| module.exports = { isJunkZipPath, sanitizeZipBuffer }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
+131
-8
@@ -11,2 +11,8 @@ #!/usr/bin/env node | ||
| const { detectSystemLanguage } = require('../src/utils/detect-language'); | ||
| const { | ||
| resolveDataDir, | ||
| getDaemonStatus, | ||
| startDaemon, | ||
| stopDaemon | ||
| } = require('../src/utils/daemon'); | ||
@@ -37,2 +43,5 @@ const appLanguage = detectSystemLanguage(); | ||
| -v, --verbose 启用调试信息 | ||
| --daemon 后台运行(无需 pm2;PID/日志在数据目录;默认关闭 Cappy) | ||
| --stop 停止 --daemon 启动的进程 | ||
| --status 查看后台进程状态 | ||
| --help 显示帮助信息 | ||
@@ -51,2 +60,5 @@ --version 显示版本号 | ||
| npx skill-base --no-cappy # 禁用吉祥物 | ||
| npx skill-base -d ./skill-data --daemon -p 8000 # 后台启动 | ||
| npx skill-base -d ./skill-data --status # 查看状态 | ||
| npx skill-base -d ./skill-data --stop # 停止后台进程 | ||
| `, | ||
@@ -68,2 +80,5 @@ en: ` | ||
| -v, --verbose Enable debug logs | ||
| --daemon Run in background (no pm2; PID/log in data dir; Cappy off by default) | ||
| --stop Stop a --daemon process | ||
| --status Show background process status | ||
| --help Show help | ||
@@ -82,5 +97,10 @@ --version Show version | ||
| npx skill-base --no-cappy # Disable the mascot | ||
| npx skill-base -d ./skill-data --daemon -p 8000 # Run in background | ||
| npx skill-base -d ./skill-data --status # Check status | ||
| npx skill-base -d ./skill-data --stop # Stop background process | ||
| ` | ||
| }; | ||
| const DAEMON_FLAGS = new Set(['--daemon', '--stop', '--status']); | ||
| // 解析命令行参数 | ||
@@ -96,2 +116,5 @@ const args = process.argv.slice(2); | ||
| let sessionStore = 'memory'; | ||
| let runDaemon = false; | ||
| let stopDaemonMode = false; | ||
| let statusDaemon = false; | ||
@@ -128,2 +151,8 @@ for (let i = 0; i < args.length; i++) { | ||
| debug = true; | ||
| } else if (args[i] === '--daemon') { | ||
| runDaemon = true; | ||
| } else if (args[i] === '--stop') { | ||
| stopDaemonMode = true; | ||
| } else if (args[i] === '--status') { | ||
| statusDaemon = true; | ||
| } else if (args[i] === '--help') { | ||
@@ -139,2 +168,64 @@ console.log(pickMessage(helpText)); | ||
| if (runDaemon) { | ||
| enableCappy = false; | ||
| } | ||
| const resolvedDataDir = resolveDataDir(dataDir); | ||
| const daemonActionCount = [runDaemon, stopDaemonMode, statusDaemon].filter(Boolean).length; | ||
| if (daemonActionCount > 1) { | ||
| console.error(pickMessage({ | ||
| zh: '错误: --daemon、--stop、--status 不能同时使用', | ||
| en: 'Error: --daemon, --stop, and --status cannot be used together' | ||
| })); | ||
| process.exit(1); | ||
| } | ||
| if (stopDaemonMode) { | ||
| try { | ||
| const result = stopDaemon(resolvedDataDir); | ||
| if (result.wasRunning) { | ||
| console.log(pickMessage({ | ||
| zh: `已停止 Skill Base(PID ${result.pid}${result.force ? ',已强制结束' : ''})`, | ||
| en: `Stopped Skill Base (PID ${result.pid}${result.force ? ', forced' : ''})` | ||
| })); | ||
| process.exit(0); | ||
| } | ||
| console.log(pickMessage({ | ||
| zh: result.stale | ||
| ? `Skill Base 未在运行(已清理过期 PID 文件)` | ||
| : 'Skill Base 未在运行', | ||
| en: result.stale | ||
| ? 'Skill Base is not running (removed stale PID file)' | ||
| : 'Skill Base is not running' | ||
| })); | ||
| process.exit(result.stale ? 0 : 1); | ||
| } catch (err) { | ||
| console.error(pickMessage({ | ||
| zh: `停止失败: ${err.message}`, | ||
| en: `Failed to stop: ${err.message}` | ||
| })); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| if (statusDaemon) { | ||
| const status = getDaemonStatus(resolvedDataDir); | ||
| if (status.running) { | ||
| console.log(pickMessage({ | ||
| zh: `Skill Base 正在运行(PID ${status.pid},数据目录 ${resolvedDataDir})`, | ||
| en: `Skill Base is running (PID ${status.pid}, data dir ${resolvedDataDir})` | ||
| })); | ||
| process.exit(0); | ||
| } | ||
| console.log(pickMessage({ | ||
| zh: status.stale | ||
| ? `Skill Base 未在运行(已清理过期 PID 文件,数据目录 ${resolvedDataDir})` | ||
| : `Skill Base 未在运行(数据目录 ${resolvedDataDir})`, | ||
| en: status.stale | ||
| ? `Skill Base is not running (removed stale PID file, data dir ${resolvedDataDir})` | ||
| : `Skill Base is not running (data dir ${resolvedDataDir})` | ||
| })); | ||
| process.exit(1); | ||
| } | ||
| // 设置环境变量 | ||
@@ -150,13 +241,12 @@ process.env.PORT = port; | ||
| // 设置数据目录 | ||
| if (dataDir) { | ||
| // 确保目录存在 | ||
| if (!fs.existsSync(dataDir)) { | ||
| fs.mkdirSync(dataDir, { recursive: true }); | ||
| if (dataDir || runDaemon) { | ||
| if (!fs.existsSync(resolvedDataDir)) { | ||
| fs.mkdirSync(resolvedDataDir, { recursive: true }); | ||
| } | ||
| process.env.DATA_DIR = dataDir; | ||
| process.env.DATABASE_PATH = path.join(dataDir, 'skills.db'); | ||
| process.env.DATA_DIR = resolvedDataDir; | ||
| process.env.DATABASE_PATH = path.join(resolvedDataDir, 'skills.db'); | ||
| console.log( | ||
| pickMessage({ | ||
| zh: `数据目录: ${dataDir}`, | ||
| en: `Data directory: ${dataDir}` | ||
| zh: `数据目录: ${resolvedDataDir}`, | ||
| en: `Data directory: ${resolvedDataDir}` | ||
| }) | ||
@@ -166,3 +256,36 @@ ); | ||
| if (runDaemon) { | ||
| const childArgs = args.filter((arg) => !DAEMON_FLAGS.has(arg)); | ||
| if (!childArgs.includes('--no-cappy')) { | ||
| childArgs.push('--no-cappy'); | ||
| } | ||
| try { | ||
| const { pid, logFile } = startDaemon({ | ||
| entryScript: __filename, | ||
| args: childArgs, | ||
| dataDir: resolvedDataDir, | ||
| env: { ...process.env } | ||
| }); | ||
| console.log(pickMessage({ | ||
| zh: `Skill Base 已在后台启动(PID ${pid})\n日志: ${logFile}\n停止: npx skill-base -d ${dataDir || resolvedDataDir} --stop`, | ||
| en: `Skill Base started in background (PID ${pid})\nLog: ${logFile}\nStop: npx skill-base -d ${dataDir || resolvedDataDir} --stop` | ||
| })); | ||
| process.exit(0); | ||
| } catch (err) { | ||
| if (err.message === 'ALREADY_RUNNING') { | ||
| console.error(pickMessage({ | ||
| zh: `Skill Base 已在运行(PID ${err.pid})`, | ||
| en: `Skill Base is already running (PID ${err.pid})` | ||
| })); | ||
| process.exit(1); | ||
| } | ||
| console.error(pickMessage({ | ||
| zh: `后台启动失败: ${err.message}`, | ||
| en: `Failed to start daemon: ${err.message}` | ||
| })); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| // 启动服务 | ||
| require('../src/index.js'); |
+2
-0
@@ -15,2 +15,3 @@ # Documentation | ||
| | [Author](author.md) | Maintenance contract, Author's Lab | | ||
| | [Webhook](webhook.md) | Configure, payloads, local demo | | ||
| | [Changelog](../CHANGELOG.md) | Server / npm release notes | | ||
@@ -33,4 +34,5 @@ | ||
| | [API](zh/api.md) | HTTP API | | ||
| | [Webhook 指南](zh/webhook.md) | 配置、事件载荷、本地 Demo | | ||
| | [更新日志](../CHANGELOG.md) | 服务端 / npm 版本说明 | | ||
| Project README: [English](../README.md) · [中文](zh/README.md) |
@@ -85,2 +85,3 @@ # Skill Base | ||
| | [API](api.md) | HTTP API | | ||
| | [Webhook 指南](webhook.md) | 配置、事件载荷、本地 Demo | | ||
| | [更新日志](../CHANGELOG.md) | 服务端 / npm 版本说明 | | ||
@@ -87,0 +88,0 @@ | [作者与维护](author.md) | 维护契约、Author's Lab | |
+1
-1
| { | ||
| "name": "skill-base", | ||
| "version": "2.0.47", | ||
| "version": "2.0.48", | ||
| "description": "Skill Base - A lightweight, privately deployable agent skills distribution hub", | ||
@@ -5,0 +5,0 @@ "main": "src/index.js", |
+4
-2
@@ -34,4 +34,4 @@ # Skill Base | ||
| | **Publish** | Web upload, `skb publish`, or GitHub import from public repos | | ||
| | **Install / update** | `skb install` / `skb update` with IDE Skill paths and local install tracking | | ||
| | **Browse** | Web UI for search, version switching, changelogs, tags, favorites | | ||
| | **Install / update / delete** | `skb install` / `skb update` / `skb delete` with IDE Skill paths and local install tracking; `skb install --collection <id-or-slug>` for curated packs | | ||
| | **Browse** | Web UI for search, version switching, changelogs, tags, collections (max 10 skills), favorites | | ||
| | **Desktop** | Native desktop client — [download](docs/desktop.md) | | ||
@@ -61,2 +61,4 @@ | **Visibility** | `public` / `private` skills; owner / collaborator / user permissions | | ||
| skb install some-skill --ide cursor | ||
| skb install --collection 1 --ide cursor | ||
| skb install --collection frontend-team --ide cursor | ||
| skb login # required for publish | ||
@@ -63,0 +65,0 @@ skb publish ./my-skill --changelog "First release" |
+4
-1
@@ -253,3 +253,6 @@ const { execFileSync } = require('child_process'); | ||
| require('./migrations/005-skill-favorites-tags-downloads-super-admin'), | ||
| require('./migrations/006-skill-visibility') | ||
| require('./migrations/006-skill-visibility'), | ||
| require('./migrations/007-collections'), | ||
| require('./migrations/008-collection-download-count'), | ||
| require('./migrations/009-collection-slug') | ||
| ]; | ||
@@ -256,0 +259,0 @@ |
+18
-0
@@ -165,2 +165,3 @@ const fs = require('fs'); | ||
| await fastify.register(require('./routes/tags'), { prefix: `${API_PREFIX}/tags` }); | ||
| await fastify.register(require('./routes/collections'), { prefix: `${API_PREFIX}/collections` }); | ||
| await fastify.register(require('./routes/users'), { prefix: `${API_PREFIX}/users` }); | ||
@@ -238,2 +239,19 @@ debugLog({ zh: 'API 路由已注册。', en: 'API routes registered.' }); | ||
| const shutdown = async (signal) => { | ||
| debugLog( | ||
| { zh: `收到 ${signal},正在关闭服务...`, en: `Received ${signal}, shutting down...` } | ||
| ); | ||
| try { | ||
| if (enableCappy && cappy) { | ||
| cappy.stop?.(); | ||
| } | ||
| await fastify.close(); | ||
| } catch { | ||
| // ignore shutdown errors | ||
| } | ||
| process.exit(0); | ||
| }; | ||
| process.on('SIGTERM', () => shutdown('SIGTERM')); | ||
| process.on('SIGINT', () => shutdown('SIGINT')); | ||
| await fastify.listen({ port: PORT, host: HOST }); | ||
@@ -240,0 +258,0 @@ infoLog({ |
@@ -8,2 +8,3 @@ const fs = require('fs'); | ||
| const TagModel = require('../models/tag'); | ||
| const CollectionModel = require('../models/collection'); | ||
| const { getZipPath, resolveZipPath } = require('../utils/zip'); | ||
@@ -39,2 +40,3 @@ const { canManageSkill, canViewSkill } = require('../utils/permission'); | ||
| tags: TagModel.listSkillTags(skill.id), | ||
| collections: CollectionModel.listSkillCollections(skill.id), | ||
| owner: { | ||
@@ -41,0 +43,0 @@ id: skill.owner_id, |
+3
-14
| const TagModel = require('../models/tag'); | ||
| const { isSuperAdmin } = require('../utils/permission'); | ||
| async function tagsRoutes(fastify) { | ||
| async function requireSuperAdmin(request, reply) { | ||
| if (!isSuperAdmin(request.user)) { | ||
| return reply.code(403).send({ | ||
| ok: false, | ||
| error: 'forbidden', | ||
| detail: 'Super admin permission required' | ||
| }); | ||
| } | ||
| } | ||
| fastify.get('/', { | ||
@@ -22,3 +11,3 @@ preHandler: [fastify.authenticate] | ||
| fastify.post('/', { | ||
| preHandler: [fastify.authenticate, requireSuperAdmin] | ||
| preHandler: [fastify.requireAdmin] | ||
| }, async (request, reply) => { | ||
@@ -35,3 +24,3 @@ const name = String(request.body?.name || '').trim(); | ||
| fastify.patch('/:tag_id', { | ||
| preHandler: [fastify.authenticate, requireSuperAdmin] | ||
| preHandler: [fastify.requireAdmin] | ||
| }, async (request, reply) => { | ||
@@ -55,3 +44,3 @@ const tagId = parseInt(request.params.tag_id, 10); | ||
| fastify.delete('/:tag_id', { | ||
| preHandler: [fastify.authenticate, requireSuperAdmin] | ||
| preHandler: [fastify.requireAdmin] | ||
| }, async (request, reply) => { | ||
@@ -58,0 +47,0 @@ const tagId = parseInt(request.params.tag_id, 10); |
| const AdmZip = require('adm-zip'); | ||
| const { isJunkZipPath } = require('./zip-sanitize'); | ||
| const { | ||
@@ -203,2 +204,3 @@ slugRepoNameForSkillId, | ||
| const name = entry.entryName.replace(/\\/g, '/'); | ||
| if (isJunkZipPath(name)) continue; | ||
| if (!name.startsWith(skillPrefix)) continue; | ||
@@ -205,0 +207,0 @@ |
@@ -7,2 +7,3 @@ const fs = require('fs'); | ||
| const { ensureSkillDir, generateVersionNumber, getZipPath, getZipRelativePath } = require('./zip'); | ||
| const { sanitizeZipBuffer } = require('./zip-sanitize'); | ||
| const { canPublishSkill } = require('./permission'); | ||
@@ -60,4 +61,5 @@ const { notifySkillWebhook } = require('./skill-webhook'); | ||
| const zipPath = getZipPath(skill_id, version); | ||
| const cleanZipBuffer = sanitizeZipBuffer(zipBuffer); | ||
| try { | ||
| fs.writeFileSync(zipPath, zipBuffer, { flag: 'wx' }); | ||
| fs.writeFileSync(zipPath, cleanZipBuffer, { flag: 'wx' }); | ||
| } catch (error) { | ||
@@ -64,0 +66,0 @@ if (error && error.code === 'EEXIST') { |
@@ -24,4 +24,4 @@ <!DOCTYPE html> | ||
| </script> | ||
| <script type="module" crossorigin src="./assets/index-Dt5gKysK.js"></script> | ||
| <link rel="stylesheet" crossorigin href="./assets/index-DAGEvNqR.css"> | ||
| <script type="module" crossorigin src="./assets/index-Boz0sxj7.js"></script> | ||
| <link rel="stylesheet" crossorigin href="./assets/index-0H-ZR03J.css"> | ||
| </head> | ||
@@ -28,0 +28,0 @@ <body> |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
14529846
0.68%149
5.67%12003
10.84%115
1.77%51
10.87%12
9.09%