🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

skill-base

Package Overview
Dependencies
Maintainers
1
Versions
45
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

skill-base - npm Package Compare versions

Comparing version
2.0.47
to
2.0.48
+37
src/migrations/007-collections.js
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');

@@ -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",

@@ -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"

@@ -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 @@

@@ -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,

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