Compare commits
8 Commits
f86cace07d
...
4c3fbd94ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c3fbd94ec | |||
| 014c495d71 | |||
| 7bb3ef8d09 | |||
| 1778badacf | |||
| 9cf9b438d5 | |||
| ed583c3a66 | |||
| 484717f88e | |||
| 0bc0e08c99 |
@ -4,6 +4,8 @@ DB_NAME=skills_market
|
||||
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
# Root admins (comma-separated). Legacy ADMIN_EMAIL is still supported.
|
||||
ADMIN_EMAILS=admin@example.com,ops@example.com
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
scripts/
|
||||
@ -61,7 +61,7 @@ npm run dev
|
||||
- 登录方式:管理员邮箱验证码登录(`/api/auth/send-code` + `/api/admin/login`)
|
||||
- 管理能力:用户列表、角色切换、权限配置(模式可见性 / 技能页 / 智能体页 / SSH 页 / 开发者模式)
|
||||
- 相关接口:`/api/admin/login`、`/api/admin/users`、`/api/admin/users/:userId/permissions`、`/api/admin/audit-logs`
|
||||
- 权限规则:`ADMIN_EMAIL` 为初始管理员(root admin);其他管理员只能编辑非 admin 用户,且不能修改自己的权限
|
||||
- 权限规则:`ADMIN_EMAILS`(逗号分隔)定义初始管理员(root admin,兼容旧的 `ADMIN_EMAIL`);其他管理员只能编辑非 admin 用户,且不能修改自己的权限
|
||||
|
||||
## 环境变量
|
||||
|
||||
|
||||
@ -482,6 +482,7 @@
|
||||
document.getElementById('userCount').textContent = String(users.length || 0)
|
||||
document.getElementById('editableCount').textContent = String(users.filter((u) => !!u.editable).length)
|
||||
const grantable = adminCapabilities.grantable_permissions || {}
|
||||
const canEditAdmins = !!adminCapabilities.canEditAdmins
|
||||
const grantableModes = Array.isArray(grantable.allowedModes) ? grantable.allowedModes : []
|
||||
tbody.innerHTML = users
|
||||
.map((u) => {
|
||||
@ -495,6 +496,16 @@
|
||||
)
|
||||
.join(' ')
|
||||
const editable = !!u.editable
|
||||
const roleOptions = [
|
||||
`<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>`,
|
||||
...(u.role === 'admin' || canEditAdmins
|
||||
? [`<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>`]
|
||||
: [])
|
||||
].join('')
|
||||
const roleHint =
|
||||
!canEditAdmins && u.role !== 'admin'
|
||||
? '<div class="muted">仅初始管理员可授予 admin</div>'
|
||||
: ''
|
||||
|
||||
return `
|
||||
<tr data-id="${u.id}">
|
||||
@ -505,9 +516,9 @@
|
||||
<td>
|
||||
<div class="role ${u.role === 'admin' ? 'role-admin' : 'role-user'}">${u.role}</div>
|
||||
<select data-field="role" ${editable ? '' : 'disabled'}>
|
||||
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
|
||||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
|
||||
${roleOptions}
|
||||
</select>
|
||||
${roleHint}
|
||||
</td>
|
||||
<td><div class="mode-list">${modeChecks || '<span class="muted">未授权</span>'}</div></td>
|
||||
<td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td>
|
||||
@ -559,7 +570,7 @@
|
||||
}
|
||||
const data = await api('/api/auth/send-code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email })
|
||||
body: JSON.stringify({ email, adminOnly: true })
|
||||
})
|
||||
if (!data.success) {
|
||||
notify(data.error || '发送验证码失败', 'error')
|
||||
|
||||
@ -4,7 +4,17 @@ const { sendVerificationCode, verifyCode } = require('../services/auth')
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'
|
||||
const ROOT_ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase()
|
||||
const ROOT_ADMIN_EMAILS = Array.from(
|
||||
new Set(
|
||||
[
|
||||
...(process.env.ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
(process.env.ADMIN_EMAIL || '').trim().toLowerCase()
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
|
||||
const WHITELIST_CODE = process.env.WHITELIST_CODE || '888888'
|
||||
const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '')
|
||||
@ -54,7 +64,8 @@ function normalizeRole(role) {
|
||||
}
|
||||
|
||||
function isRootAdminEmail(email) {
|
||||
return !!ROOT_ADMIN_EMAIL && String(email || '').toLowerCase() === ROOT_ADMIN_EMAIL
|
||||
const emailLower = String(email || '').toLowerCase()
|
||||
return emailLower.length > 0 && ROOT_ADMIN_EMAILS.includes(emailLower)
|
||||
}
|
||||
|
||||
function sanitizeUserForClient(userDoc) {
|
||||
@ -170,38 +181,40 @@ function createAuthRoutes(db) {
|
||||
return {
|
||||
async ensureAdminBootstrap() {
|
||||
try {
|
||||
if (!ROOT_ADMIN_EMAIL) return
|
||||
if (ROOT_ADMIN_EMAILS.length === 0) return
|
||||
const now = new Date()
|
||||
const existing = await usersCollection.findOne({ email: ROOT_ADMIN_EMAIL })
|
||||
if (!existing) {
|
||||
await usersCollection.insertOne({
|
||||
email: ROOT_ADMIN_EMAIL,
|
||||
nickname: ROOT_ADMIN_EMAIL.split('@')[0],
|
||||
avatar: null,
|
||||
role: 'admin',
|
||||
permissions: getAdminDefaultPermissions(),
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login: null
|
||||
})
|
||||
console.log('[Auth] Root admin bootstrap user created')
|
||||
return
|
||||
}
|
||||
await usersCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
for (const rootEmail of ROOT_ADMIN_EMAILS) {
|
||||
const existing = await usersCollection.findOne({ email: rootEmail })
|
||||
if (!existing) {
|
||||
await usersCollection.insertOne({
|
||||
email: rootEmail,
|
||||
nickname: rootEmail.split('@')[0],
|
||||
avatar: null,
|
||||
role: 'admin',
|
||||
permissions: sanitizePermissions({
|
||||
...getAdminDefaultPermissions(),
|
||||
...(existing.permissions || {})
|
||||
}),
|
||||
updated_at: now
|
||||
}
|
||||
permissions: getAdminDefaultPermissions(),
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login: null
|
||||
})
|
||||
console.log('[Auth] Root admin bootstrap user created:', rootEmail)
|
||||
continue
|
||||
}
|
||||
)
|
||||
console.log('[Auth] Root admin bootstrap user ensured')
|
||||
await usersCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
role: 'admin',
|
||||
permissions: sanitizePermissions({
|
||||
...getAdminDefaultPermissions(),
|
||||
...(existing.permissions || {})
|
||||
}),
|
||||
updated_at: now
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log('[Auth] Root admin bootstrap user ensured:', rootEmail)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] ensureAdminBootstrap error:', err)
|
||||
}
|
||||
@ -209,14 +222,24 @@ function createAuthRoutes(db) {
|
||||
|
||||
async sendCode(req, res) {
|
||||
try {
|
||||
const { email } = req.body
|
||||
const { email, adminOnly } = req.body
|
||||
if (!email) {
|
||||
return res.status(400).json({ success: false, error: '邮箱不能为空' })
|
||||
}
|
||||
if (WHITELIST_EMAILS.includes(email.toLowerCase())) {
|
||||
const emailLower = String(email).trim().toLowerCase()
|
||||
|
||||
if (adminOnly === true) {
|
||||
const user = await usersCollection.findOne({ email: emailLower })
|
||||
const canLoginAdmin = !!(user && isAdmin(user))
|
||||
if (!canLoginAdmin) {
|
||||
return res.status(403).json({ success: false, error: '仅管理员可获取登录验证码' })
|
||||
}
|
||||
}
|
||||
|
||||
if (WHITELIST_EMAILS.includes(emailLower)) {
|
||||
return res.json({ success: true, message: '验证码已发送' })
|
||||
}
|
||||
const result = await sendVerificationCode(db, email.toLowerCase())
|
||||
const result = await sendVerificationCode(db, emailLower)
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result)
|
||||
}
|
||||
|
||||
@ -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✅ 导入完成')
|
||||
|
||||
88
server.js
88
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 }
|
||||
|
||||
@ -104,6 +131,12 @@ function getActiveLock(doc) {
|
||||
return { userId: doc.lock.userId, by: doc.lock.nickname || doc.lock.by || doc.lock.userId, at: doc.lock.at }
|
||||
}
|
||||
|
||||
function sameUserId(a, b) {
|
||||
const lhs = String(a ?? '').trim()
|
||||
const rhs = String(b ?? '').trim()
|
||||
return lhs.length > 0 && rhs.length > 0 && lhs === rhs
|
||||
}
|
||||
|
||||
function extractDescription(files) {
|
||||
const skillFile = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('SKILL.md'))
|
||||
if (!skillFile) return ''
|
||||
@ -388,12 +421,15 @@ 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' })
|
||||
}
|
||||
const activeLock = getActiveLock(skill)
|
||||
if (activeLock && activeLock.userId !== req.user.id) {
|
||||
if (activeLock && !sameUserId(activeLock.userId, req.user.id)) {
|
||||
return res.status(423).json({ success: false, error: `${activeLock.by} 正在编辑`, locked_by: activeLock.by })
|
||||
}
|
||||
await skillsCollection.updateOne(
|
||||
@ -410,13 +446,16 @@ 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' })
|
||||
}
|
||||
const activeLock = getActiveLock(skill)
|
||||
const isAdmin = req.user.role === 'admin'
|
||||
if (activeLock && activeLock.userId !== req.user.id && !isAdmin) {
|
||||
if (activeLock && !sameUserId(activeLock.userId, req.user.id) && !isAdmin) {
|
||||
return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' })
|
||||
}
|
||||
await skillsCollection.updateOne(
|
||||
@ -473,10 +512,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
|
||||
|
||||
@ -492,7 +550,7 @@ app.post('/api/skills/:name/publish', async (req, res) => {
|
||||
|
||||
if (existingSkill) {
|
||||
const activeLock = getActiveLock(existingSkill)
|
||||
if (activeLock && activeLock.userId !== userId) {
|
||||
if (activeLock && !sameUserId(activeLock.userId, userId)) {
|
||||
return res.status(423).json({
|
||||
success: false,
|
||||
error: `${activeLock.by} 正在编辑,暂时不能发布`,
|
||||
@ -520,6 +578,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 +594,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 +838,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
|
||||
@ -786,7 +852,7 @@ app.post('/api/agents/:name/publish', async (req, res) => {
|
||||
|
||||
if (existing) {
|
||||
const activeLock = getActiveLock(existing)
|
||||
if (activeLock && activeLock.userId !== userId) {
|
||||
if (activeLock && !sameUserId(activeLock.userId, userId)) {
|
||||
return res.status(423).json({
|
||||
success: false,
|
||||
error: `${activeLock.by} 正在编辑,暂时不能发布`,
|
||||
@ -850,10 +916,13 @@ 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)
|
||||
if (activeLock && activeLock.userId !== req.user.id) {
|
||||
if (activeLock && !sameUserId(activeLock.userId, req.user.id)) {
|
||||
return res.status(423).json({ success: false, error: `${activeLock.by} 正在编辑`, locked_by: activeLock.by })
|
||||
}
|
||||
await agentsCollection.updateOne({ _id: agent._id }, { $set: { lock: { userId: req.user.id, nickname: req.user.nickname || req.user.email, at: new Date().toISOString() } } })
|
||||
@ -867,11 +936,14 @@ 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)
|
||||
const isAdmin = req.user.role === 'admin'
|
||||
if (activeLock && activeLock.userId !== req.user.id && !isAdmin) {
|
||||
if (activeLock && !sameUserId(activeLock.userId, req.user.id) && !isAdmin) {
|
||||
return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' })
|
||||
}
|
||||
await agentsCollection.updateOne({ _id: agent._id }, { $unset: { lock: '' } })
|
||||
|
||||
281
updates/index.html
Normal file
281
updates/index.html
Normal file
@ -0,0 +1,281 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LikeCowork 安装指南</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 32px;
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.platform-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.platform-title {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.step-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.step-content {
|
||||
flex: 1;
|
||||
}
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.step-desc {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.download-btn {
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 10px 5px 10px 0;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.terminal {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.terminal .cmd { color: #9cdcfe; }
|
||||
.terminal .path { color: #ce9178; }
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.warning-title {
|
||||
font-weight: 600;
|
||||
color: #856404;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.copy-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.copy-btn:hover { background: #218838; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>📦 LikeCowork 安装指南</h1>
|
||||
<p class="subtitle">Mac / Windows 双平台桌面应用</p>
|
||||
|
||||
<div class="platform-section">
|
||||
<h2 class="platform-title">🍎 Mac 安装 <span id="version-mac" style="font-size:0.7em;color:#666;"></span></h2>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">下载安装包</div>
|
||||
<div class="step-desc">根据您的 Mac 芯片选择对应版本:</div>
|
||||
<div>
|
||||
<a id="mac-arm64" href="likecowork-1.1.0-arm64-mac.zip" class="download-btn">Apple Silicon (M1/M2/M3)</a>
|
||||
<a id="mac-x64" href="likecowork-1.1.0-x64-mac.zip" class="download-btn">Intel 芯片</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">解压并修复(重要!)</div>
|
||||
<div class="step-desc">解压后,如果打开应用提示"已损坏",请运行以下命令:</div>
|
||||
<div class="terminal">
|
||||
<span class="cmd">xattr -cr</span> <span class="path">~/Downloads/LikeCowork.app</span>
|
||||
<button class="copy-btn" onclick="copyCmd('mac')">复制</button>
|
||||
</div>
|
||||
<div class="warning">
|
||||
<div class="warning-title">⚠️ 提示</div>
|
||||
<div>如果解压后找不到应用,请把压缩包解压到「下载」文件夹,然后再运行上述命令。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">移动到应用程序</div>
|
||||
<div class="step-desc">将 <strong>LikeCowork.app</strong> 拖入「应用程序」文件夹即可。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-section">
|
||||
<h2 class="platform-title">🪟 Windows 安装 <span id="version-win" style="font-size:0.7em;color:#666;"></span></h2>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">下载安装包</div>
|
||||
<div class="step-desc"></div>
|
||||
<div>
|
||||
<a id="win-setup" href="likecowork-1.1.0-setup.exe" class="download-btn">下载 Windows 安装包</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">运行安装程序</div>
|
||||
<div class="step-desc">双击运行安装包,按提示完成安装即可。安装完成后可选择立即运行。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-section">
|
||||
<h2 class="platform-title">❓ 常见问题</h2>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-content">
|
||||
<div class="step-title">如何判断我的 Mac 是 Intel 还是 Apple Silicon?</div>
|
||||
<div class="step-desc">点击 Apple 菜单 → 关于本机 → 概览,如果显示「芯片」就是 Apple Silicon,「处理器」就是 Intel。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-content">
|
||||
<div class="step-title">Mac 提示"已损坏"无法打开?</div>
|
||||
<div class="step-desc">这是 macOS 安全限制。请在终端运行:<code style="background:#f5f5f5;padding:2px 6px;border-radius:4px;">xattr -cr ~/Downloads/LikeCowork.app</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 动态获取最新版本
|
||||
async function fetchLatestVersion() {
|
||||
try {
|
||||
// 使用绝对路径,避免不同访问路径下的相对路径偏差
|
||||
const [macRes, winRes] = await Promise.all([
|
||||
fetch('/updates/latest-mac.yml'),
|
||||
fetch('/updates/latest.yml')
|
||||
]);
|
||||
|
||||
if (!macRes.ok) {
|
||||
console.warn('latest-mac.yml 不可用,保留页面默认下载链接', macRes.status);
|
||||
} else {
|
||||
const macText = await macRes.text();
|
||||
const macMatch = macText.match(/version:\s*([0-9]+\.[0-9]+\.[0-9]+)/);
|
||||
if (macMatch) {
|
||||
const version = macMatch[1];
|
||||
document.getElementById('version-mac').textContent = `(v${version})`;
|
||||
document.getElementById('mac-arm64').href = `likecowork-${version}-arm64-mac.zip`;
|
||||
document.getElementById('mac-x64').href = `likecowork-${version}-x64-mac.zip`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!winRes.ok) {
|
||||
console.warn('latest.yml 不可用,保留页面默认下载链接', winRes.status);
|
||||
} else {
|
||||
const winText = await winRes.text();
|
||||
const winMatch = winText.match(/version:\s*([0-9]+\.[0-9]+\.[0-9]+)/);
|
||||
if (winMatch) {
|
||||
const version = winMatch[1];
|
||||
document.getElementById('version-win').textContent = `(v${version})`;
|
||||
document.getElementById('win-setup').href = `likecowork-${version}-setup.exe`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取版本失败,已使用页面默认下载链接:', e);
|
||||
}
|
||||
}
|
||||
|
||||
fetchLatestVersion();
|
||||
|
||||
function copyCmd(type) {
|
||||
const cmds = {
|
||||
'mac': 'xattr -cr ~/Downloads/LikeCowork.app'
|
||||
};
|
||||
const text = cmds[type];
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('已复制到剪贴板!');
|
||||
});
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
alert('已复制到剪贴板!');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
updates/latest-mac.yml
Normal file
11
updates/latest-mac.yml
Normal file
@ -0,0 +1,11 @@
|
||||
version: 1.2.1
|
||||
files:
|
||||
- url: likecowork-1.2.1-x64-mac.zip
|
||||
sha512: Kc7ApHzbJriMLAix4+QPr33Pf4pxblhAsFWr+t0WsrC3WVTaqeLMtqV+gcssOpt71Heeiqn4ajrBh0Vm0fAKwg==
|
||||
size: 1111330195
|
||||
- url: likecowork-1.2.1-arm64-mac.zip
|
||||
sha512: VgCRrK7EWvnJNkVI1epSfo+eHauYMvkmKQwohRgKSGHlvBBXbXRAaLjgQwsVFxNyWgQDLpgWkFoTBh82NdX33w==
|
||||
size: 1106558964
|
||||
path: likecowork-1.2.1-x64-mac.zip
|
||||
sha512: Kc7ApHzbJriMLAix4+QPr33Pf4pxblhAsFWr+t0WsrC3WVTaqeLMtqV+gcssOpt71Heeiqn4ajrBh0Vm0fAKwg==
|
||||
releaseDate: '2026-03-27T10:13:42.792Z'
|
||||
8
updates/latest.yml
Normal file
8
updates/latest.yml
Normal file
@ -0,0 +1,8 @@
|
||||
version: 1.2.1
|
||||
files:
|
||||
- url: likecowork-1.2.1-setup.exe
|
||||
sha512: 5qJgLTYhtQ19uVAn2BTe5UMmcIbGbQ2AowUBLOJUP4iDzseuo18jQqbfUhGDLGgGk2kUxeoZK4+ygb0apk6M6Q==
|
||||
size: 1082244709
|
||||
path: likecowork-1.2.1-setup.exe
|
||||
sha512: 5qJgLTYhtQ19uVAn2BTe5UMmcIbGbQ2AowUBLOJUP4iDzseuo18jQqbfUhGDLGgGk2kUxeoZK4+ygb0apk6M6Q==
|
||||
releaseDate: '2026-03-27T10:05:08.209Z'
|
||||
BIN
updates/likecowork-1.2.1-arm64-mac.zip
Normal file
BIN
updates/likecowork-1.2.1-arm64-mac.zip
Normal file
Binary file not shown.
BIN
updates/likecowork-1.2.1-arm64-mac.zip.blockmap
Normal file
BIN
updates/likecowork-1.2.1-arm64-mac.zip.blockmap
Normal file
Binary file not shown.
BIN
updates/likecowork-1.2.1-setup.exe
Normal file
BIN
updates/likecowork-1.2.1-setup.exe
Normal file
Binary file not shown.
BIN
updates/likecowork-1.2.1-setup.exe.blockmap
Normal file
BIN
updates/likecowork-1.2.1-setup.exe.blockmap
Normal file
Binary file not shown.
BIN
updates/likecowork-1.2.1-x64-mac.zip
Normal file
BIN
updates/likecowork-1.2.1-x64-mac.zip
Normal file
Binary file not shown.
BIN
updates/likecowork-1.2.1-x64-mac.zip.blockmap
Normal file
BIN
updates/likecowork-1.2.1-x64-mac.zip.blockmap
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user