feat(server): enhance skill and agent import functionality
Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
- Added support for clearing version history during skill and agent imports with the `--clear-versions` flag. - Updated import scripts to handle versioning more effectively, ensuring only the latest version is retained when clearing history. - Introduced new checks for user permissions in various API endpoints to restrict access based on roles. - Normalized file paths during merges to ensure consistency across different operating systems. - Updated `.gitignore` to exclude the `scripts/` directory.
This commit is contained in:
parent
484717f88e
commit
ed583c3a66
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
scripts/
|
||||
@ -1,6 +1,8 @@
|
||||
/**
|
||||
* 将 aiscri-xiong/resources/skills 和 resources/agents 导入 MongoDB
|
||||
* 用法:node scripts/import-resources.js [--owner <name>] [--dry-run]
|
||||
* 用法:node scripts/import-resources.js [--owner <name>] [--dry-run] [--clear-versions]
|
||||
*
|
||||
* --clear-versions 更新已有 skill/agent 时清空历史 versions,只保留本次导入的一条记录(适合修复超大文档或重置历史)
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') })
|
||||
@ -11,11 +13,13 @@ const { MongoClient } = require('mongodb')
|
||||
|
||||
const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017'
|
||||
const DB_NAME = process.env.DB_NAME || 'skills_market'
|
||||
const MAX_VERSION_HISTORY = Number.parseInt(process.env.MAX_VERSION_HISTORY || '10', 10)
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const ownerIdx = args.indexOf('--owner')
|
||||
const OWNER = ownerIdx !== -1 ? args[ownerIdx + 1] : 'system'
|
||||
const DRY_RUN = args.includes('--dry-run')
|
||||
const CLEAR_VERSIONS = args.includes('--clear-versions')
|
||||
|
||||
// 路径指向 aiscri-xiong/resources
|
||||
const RESOURCES_DIR = path.join(__dirname, '../../aiscri-xiong/resources')
|
||||
@ -54,7 +58,8 @@ function extractFrontmatter(content) {
|
||||
|
||||
// ── 技能导入 ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function importSkills(skillsCollection) {
|
||||
async function importSkills(skillsCollection, options) {
|
||||
const { clearVersions } = options
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
console.log(`[skills] 目录不存在:${SKILLS_DIR}`)
|
||||
return
|
||||
@ -84,26 +89,56 @@ async function importSkills(skillsCollection) {
|
||||
const existing = await skillsCollection.findOne({ name })
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [dry-run] ${existing ? '更新' : '新建'} skill: ${name} (${files.length} 个文件)`)
|
||||
const note = existing && clearVersions ? ' [将清空旧 versions]' : ''
|
||||
console.log(` [dry-run] ${existing ? '更新' : '新建'} skill: ${name} (${files.length} 个文件)${note}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const versionEntry = {
|
||||
version: (existing.versions?.length || 0) + 1,
|
||||
description: `Imported from resources by ${OWNER}`,
|
||||
files: existing.files,
|
||||
created_at: now,
|
||||
created_by: OWNER
|
||||
}
|
||||
await skillsCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: { files, description: description || existing.description, updated_at: now, updated_by: OWNER },
|
||||
$push: { versions: versionEntry }
|
||||
if (clearVersions) {
|
||||
const versionEntry = {
|
||||
version: 1,
|
||||
description: `Imported from resources by ${OWNER} (history cleared)`,
|
||||
file_count: files.length,
|
||||
created_at: now,
|
||||
created_by: OWNER
|
||||
}
|
||||
)
|
||||
console.log(` [更新] skill: ${name} → v${versionEntry.version}`)
|
||||
await skillsCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
files,
|
||||
description: description || existing.description,
|
||||
updated_at: now,
|
||||
updated_by: OWNER,
|
||||
versions: [versionEntry]
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log(` [更新] skill: ${name} → 已清空历史,仅保留 v1`)
|
||||
} else {
|
||||
const versionEntry = {
|
||||
version: (existing.versions?.length || 0) + 1,
|
||||
description: `Imported from resources by ${OWNER}`,
|
||||
// Keep version snapshots lightweight to avoid MongoDB 16MB document limit.
|
||||
file_count: Array.isArray(existing.files) ? existing.files.length : 0,
|
||||
created_at: now,
|
||||
created_by: OWNER
|
||||
}
|
||||
await skillsCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: { files, description: description || existing.description, updated_at: now, updated_by: OWNER },
|
||||
$push: {
|
||||
versions: {
|
||||
$each: [versionEntry],
|
||||
$slice: -MAX_VERSION_HISTORY
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log(` [更新] skill: ${name} → v${versionEntry.version}`)
|
||||
}
|
||||
} else {
|
||||
await skillsCollection.insertOne({
|
||||
name,
|
||||
@ -113,7 +148,13 @@ async function importSkills(skillsCollection) {
|
||||
downloads: 0,
|
||||
is_public: true,
|
||||
tags: [],
|
||||
versions: [{ version: 1, description: 'Initial import', files, created_at: now, created_by: OWNER }],
|
||||
versions: [{
|
||||
version: 1,
|
||||
description: 'Initial import',
|
||||
file_count: files.length,
|
||||
created_at: now,
|
||||
created_by: OWNER
|
||||
}],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created_by: OWNER,
|
||||
@ -126,7 +167,8 @@ async function importSkills(skillsCollection) {
|
||||
|
||||
// ── Agent 导入 ────────────────────────────────────────────────────────────
|
||||
|
||||
async function importAgents(agentsCollection) {
|
||||
async function importAgents(agentsCollection, options) {
|
||||
const { clearVersions } = options
|
||||
if (!fs.existsSync(AGENTS_DIR)) {
|
||||
console.log(`[agents] 目录不存在:${AGENTS_DIR}`)
|
||||
return
|
||||
@ -148,25 +190,54 @@ async function importAgents(agentsCollection) {
|
||||
const existing = await agentsCollection.findOne({ name })
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [dry-run] ${existing ? '更新' : '新建'} agent: ${name}`)
|
||||
const note = existing && clearVersions ? ' [将清空旧 versions]' : ''
|
||||
console.log(` [dry-run] ${existing ? '更新' : '新建'} agent: ${name}${note}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const versionEntry = {
|
||||
version: (existing.versions?.length || 0) + 1,
|
||||
content: existing.content,
|
||||
created_at: now,
|
||||
created_by: OWNER
|
||||
}
|
||||
await agentsCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: { content, description: description || existing.description, updated_at: now, updated_by: OWNER },
|
||||
$push: { versions: versionEntry }
|
||||
if (clearVersions) {
|
||||
const versionEntry = {
|
||||
version: 1,
|
||||
content_length: content.length,
|
||||
created_at: now,
|
||||
created_by: OWNER,
|
||||
note: 'history cleared on import'
|
||||
}
|
||||
)
|
||||
console.log(` [更新] agent: ${name} → v${versionEntry.version}`)
|
||||
await agentsCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
content,
|
||||
description: description || existing.description,
|
||||
updated_at: now,
|
||||
updated_by: OWNER,
|
||||
versions: [versionEntry]
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log(` [更新] agent: ${name} → 已清空历史,仅保留 v1`)
|
||||
} else {
|
||||
const versionEntry = {
|
||||
version: (existing.versions?.length || 0) + 1,
|
||||
content_length: typeof existing.content === 'string' ? existing.content.length : 0,
|
||||
created_at: now,
|
||||
created_by: OWNER
|
||||
}
|
||||
await agentsCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: { content, description: description || existing.description, updated_at: now, updated_by: OWNER },
|
||||
$push: {
|
||||
versions: {
|
||||
$each: [versionEntry],
|
||||
$slice: -MAX_VERSION_HISTORY
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log(` [更新] agent: ${name} → v${versionEntry.version}`)
|
||||
}
|
||||
} else {
|
||||
await agentsCollection.insertOne({
|
||||
name,
|
||||
@ -174,7 +245,12 @@ async function importAgents(agentsCollection) {
|
||||
owner: OWNER,
|
||||
content,
|
||||
is_public: true,
|
||||
versions: [{ version: 1, content, created_at: now, created_by: OWNER }],
|
||||
versions: [{
|
||||
version: 1,
|
||||
content_length: content.length,
|
||||
created_at: now,
|
||||
created_by: OWNER
|
||||
}],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created_by: OWNER,
|
||||
@ -192,6 +268,7 @@ async function main() {
|
||||
console.log(`DB: ${DB_NAME}`)
|
||||
console.log(`OWNER: ${OWNER}`)
|
||||
console.log(`DRY_RUN: ${DRY_RUN}`)
|
||||
console.log(`CLEAR_VERSIONS: ${CLEAR_VERSIONS}`)
|
||||
|
||||
const client = new MongoClient(MONGO_URL)
|
||||
await client.connect()
|
||||
@ -199,8 +276,9 @@ async function main() {
|
||||
const skillsCollection = db.collection('skills')
|
||||
const agentsCollection = db.collection('agents')
|
||||
|
||||
await importSkills(skillsCollection)
|
||||
await importAgents(agentsCollection)
|
||||
const importOpts = { clearVersions: CLEAR_VERSIONS }
|
||||
await importSkills(skillsCollection, importOpts)
|
||||
await importAgents(agentsCollection, importOpts)
|
||||
|
||||
await client.close()
|
||||
console.log('\n✅ 导入完成')
|
||||
|
||||
70
server.js
70
server.js
@ -95,6 +95,33 @@ function injectConfidentialityInstruction(files) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Normalize path keys for merge (forward slashes). */
|
||||
function normalizeSkillPath(p) {
|
||||
return String(p || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming file entries over existing by path. Incoming wins on collision.
|
||||
* Paths only present on the server are kept — so a client can send only SKILL.md
|
||||
* without wiping sibling scripts/assets unless replaceAll is used on publish.
|
||||
*/
|
||||
function mergeSkillFilesByPath(existingFiles, incomingFiles) {
|
||||
const map = new Map()
|
||||
for (const f of existingFiles || []) {
|
||||
if (f && f.path != null) {
|
||||
const key = normalizeSkillPath(f.path)
|
||||
map.set(key, { ...f, path: key })
|
||||
}
|
||||
}
|
||||
for (const f of incomingFiles || []) {
|
||||
if (f && f.path != null) {
|
||||
const key = normalizeSkillPath(f.path)
|
||||
map.set(key, { ...f, path: key })
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
const LOCK_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
const PUBLIC_VISIBILITY_FILTER = { $ne: false }
|
||||
|
||||
@ -388,6 +415,9 @@ app.get('/api/skills/:name/files/*', async (req, res) => {
|
||||
app.post('/api/skills/:name/lock', async (req, res) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权编辑技能' })
|
||||
}
|
||||
const skill = await skillsCollection.findOne({ name: req.params.name })
|
||||
if (!skill) {
|
||||
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||
@ -410,6 +440,9 @@ app.post('/api/skills/:name/lock', async (req, res) => {
|
||||
app.delete('/api/skills/:name/lock', async (req, res) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权编辑技能' })
|
||||
}
|
||||
const skill = await skillsCollection.findOne({ name: req.params.name })
|
||||
if (!skill) {
|
||||
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||
@ -473,10 +506,29 @@ app.get('/api/skills/mine', async (req, res, next) => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/skills/:name/publish
|
||||
*
|
||||
* Body: { files, description?, tags?, localModifiedAt?, force?, replaceAll? }
|
||||
*
|
||||
* - `files`: array of { path, content }.
|
||||
* - `replaceAll` (default false): when updating an existing skill, false = merge by path
|
||||
* (request patches overwrite; paths not listed are kept). true = full replace of `files`
|
||||
* (omit remote-only paths — useful for folder sync / restore exact snapshot).
|
||||
*
|
||||
* Versioning: on each successful update, a new entry is appended to `versions` whose
|
||||
* `files` field is a snapshot of the **previous** top-level `files` (before this write).
|
||||
* The document root `files` is always the **latest** snapshot. So the highest version
|
||||
* number in `versions` is **not** the same as root `files` — it is one revision behind.
|
||||
*/
|
||||
app.post('/api/skills/:name/publish', async (req, res) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
const { files, description, tags, localModifiedAt } = req.body
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权发布或修改技能' })
|
||||
}
|
||||
|
||||
const { files, description, tags, localModifiedAt, replaceAll } = req.body
|
||||
const userId = req.user.id
|
||||
const userNickname = req.user.nickname || req.user.email
|
||||
|
||||
@ -520,6 +572,11 @@ app.post('/api/skills/:name/publish', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
const useReplaceAll = replaceAll === true
|
||||
const nextFiles = useReplaceAll
|
||||
? files
|
||||
: mergeSkillFilesByPath(existingSkill.files || [], files)
|
||||
|
||||
const versionEntry = {
|
||||
version: (existingSkill.versions?.length || 0) + 1,
|
||||
description: `Updated by ${userNickname}`,
|
||||
@ -531,7 +588,7 @@ app.post('/api/skills/:name/publish', async (req, res) => {
|
||||
|
||||
const updateData = {
|
||||
$set: {
|
||||
files,
|
||||
files: nextFiles,
|
||||
description: skillDescription,
|
||||
updated_at: now,
|
||||
updated_by: userId,
|
||||
@ -775,6 +832,9 @@ app.get('/api/agents/:name', async (req, res) => {
|
||||
app.post('/api/agents/:name/publish', async (req, res) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权发布或修改智能体' })
|
||||
}
|
||||
const { content, description, localModifiedAt } = req.body
|
||||
const userId = req.user.id
|
||||
const userNickname = req.user.nickname || req.user.email
|
||||
@ -850,6 +910,9 @@ app.delete('/api/agents/:name', requireAdmin(async (req, res) => {
|
||||
app.post('/api/agents/:name/lock', async (req, res, next) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权编辑智能体' })
|
||||
}
|
||||
const agent = await agentsCollection.findOne({ name: req.params.name })
|
||||
if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' })
|
||||
const activeLock = getActiveLock(agent)
|
||||
@ -867,6 +930,9 @@ app.post('/api/agents/:name/lock', async (req, res, next) => {
|
||||
app.delete('/api/agents/:name/lock', async (req, res, next) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权编辑智能体' })
|
||||
}
|
||||
const agent = await agentsCollection.findOne({ name: req.params.name })
|
||||
if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' })
|
||||
const activeLock = getActiveLock(agent)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user