feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -0,0 +1,171 @@
|
||||
/** Reconfigure the user's YCode expert packages through the production expert API. Never reads WorkBuddy. */
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
export const definitions = JSON.parse(await fs.readFile(path.join(project, 'resources/agents/general-development.json'), 'utf8'))
|
||||
const hash = value => createHash('sha256').update(value).digest('hex')
|
||||
export function packageFiles(agent) {
|
||||
const manifest = {
|
||||
name: agent.id, plugin: agent.id, agentName: agent.agentName, version: '2.1.0', expertType: 'agent',
|
||||
description: agent.description, displayName: agent.displayName, profession: agent.profession,
|
||||
displayDescription: agent.displayDescription, categoryId: '02-Engineering', author: { name: 'YCode' },
|
||||
agents: [`./agents/${agent.agentName}.md`], skills: [], tags: agent.tags,
|
||||
defaultInitPrompt: agent.quickPrompts[0], quickPrompts: agent.quickPrompts, avatar: agent.avatar,
|
||||
ycode: { generalDevelopmentRole: agent.role, definitionVersion: definitions.version }
|
||||
}
|
||||
const prompt = [`# ${agent.profession.zh}`, '', ...definitions.commonInstructions, '', '## 工作职责', '', ...agent.instructions].join('\n\n')
|
||||
return {
|
||||
'.codebuddy-plugin/plugin.json': JSON.stringify(manifest, null, 2) + '\n',
|
||||
[`agents/${agent.agentName}.md`]: `---\nname: ${agent.agentName}\ndescription: ${JSON.stringify(agent.description)}\nmaxTurns: 50\nskills: []\n---\n\n${prompt}\n`,
|
||||
'README.md': `# ${agent.profession.zh}\n\n${agent.displayDescription.zh}\n\n这是用户定制的通用开发角色,职责、提示词和快捷提问来自 YCode 的 general-development.json。\n角色配置不授予额外工具权限。\n`
|
||||
}
|
||||
}
|
||||
async function ordinaryDirectory(target) {
|
||||
const absolute = path.resolve(target)
|
||||
let current = path.parse(absolute).root
|
||||
for (const part of path.relative(current, absolute).split(path.sep)) {
|
||||
current = path.join(current, part)
|
||||
const stat = await fs.lstat(current)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('Ordinary directory required: ' + current)
|
||||
}
|
||||
}
|
||||
async function inventory(root) {
|
||||
const items = []
|
||||
async function walk(directory) {
|
||||
await ordinaryDirectory(directory)
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name)
|
||||
if (entry.isSymbolicLink()) throw Error('Links are not supported: ' + file)
|
||||
if (entry.isDirectory()) await walk(file)
|
||||
else if (entry.isFile()) items.push([path.relative(root, file), hash(await fs.readFile(file))])
|
||||
else throw Error('Ordinary file required: ' + file)
|
||||
}
|
||||
}
|
||||
await walk(root)
|
||||
return items.sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
async function avatarResource(agent) {
|
||||
if (!/^[a-z-]+$/.test(agent.role) || agent.avatar !== `./avatars/${agent.role}.png`) throw Error('Invalid general role avatar: ' + agent.id)
|
||||
const bytes = await fs.readFile(path.join(project, 'resources/agents', agent.avatar))
|
||||
if (bytes.length > 500 * 1024 || bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a'
|
||||
|| bytes.readUInt32BE(16) !== 256 || bytes.readUInt32BE(20) !== 256) throw Error('A 256px PNG avatar below 500KiB is required: ' + agent.id)
|
||||
return { bytes, url: 'data:image/png;base64,' + bytes.toString('base64'), sha256: hash(bytes) }
|
||||
}
|
||||
async function installAvatar(packageDirectory, agent, avatar) {
|
||||
await ordinaryDirectory(packageDirectory)
|
||||
const directory = path.join(packageDirectory, 'avatars')
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await ordinaryDirectory(directory)
|
||||
const target = path.join(directory, `${agent.role}.png`)
|
||||
try { const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('Avatar must be an ordinary file: ' + target) }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
const temporary = target + '.' + randomUUID() + '.tmp'
|
||||
await fs.writeFile(temporary, avatar.bytes, { flag: 'wx' })
|
||||
try { await fs.rename(temporary, target) } finally { await fs.rm(temporary, { force: true }) }
|
||||
if (hash(await fs.readFile(target)) !== avatar.sha256) throw Error('Installed avatar differs: ' + agent.id)
|
||||
}
|
||||
async function loadService(userData, directory) {
|
||||
const modulePath = path.join(directory, 'expert-service.mjs')
|
||||
await build({ stdin: { contents: "export * from './src/main/capabilities/experts'", resolveDir: project, loader: 'ts' },
|
||||
outfile: modulePath, bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning',
|
||||
plugins: [{ name: 'explicit-ycode-data', setup(builder) {
|
||||
builder.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'explicit-ycode-data' }))
|
||||
builder.onLoad({ filter: /.*/, namespace: 'explicit-ycode-data' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(userData)}};export const safeStorage={isEncryptionAvailable:()=>false}` }))
|
||||
} }] })
|
||||
return import(pathToFileURL(modulePath).href)
|
||||
}
|
||||
export async function configure(userData, { checkOnly = false } = {}) {
|
||||
userData = path.resolve(userData)
|
||||
if (path.basename(userData).toLowerCase() !== 'ycode') throw Error('An explicit YCode user-data folder is required')
|
||||
await ordinaryDirectory(userData)
|
||||
const reports = await fs.mkdtemp(path.join(project, '.tmp/general-development-agents-'))
|
||||
const service = await loadService(userData, reports)
|
||||
const marketRoot = path.join(userData, 'plugins/marketplaces/my-experts')
|
||||
const packageRoot = path.join(marketRoot, 'plugins')
|
||||
const marketFile = path.join(marketRoot, '.codebuddy-plugin/marketplace.json')
|
||||
const beforeMarket = JSON.parse(await fs.readFile(marketFile, 'utf8'))
|
||||
const ids = new Set(definitions.agents.map(agent => agent.id))
|
||||
const unrelated = beforeMarket.plugins.filter(entry => !ids.has(entry.name))
|
||||
const plans = []
|
||||
for (const agent of definitions.agents) {
|
||||
const files = packageFiles(agent), validation = service.validateExpertFiles(files, agent.id)
|
||||
const avatar = await avatarResource(agent)
|
||||
if (validation.errors.length) throw Error(validation.errors.join('\n'))
|
||||
let previous
|
||||
try { previous = await service.readExpert(agent.id) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
if (previous && previous.expert.agentName !== agent.agentName) throw Error('Agent identity changed: ' + agent.id)
|
||||
const alreadyConfigured = previous && Object.entries(files).every(([name, content]) => previous.files[name] === content)
|
||||
&& Object.keys(previous.files).every(name => name in files || name === '.created-by-session')
|
||||
&& previous.expert.avatar === avatar.url
|
||||
plans.push({ agent, files, avatar, previous, alreadyConfigured, warnings: validation.warnings })
|
||||
}
|
||||
let backup
|
||||
const changed = plans.filter(plan => !plan.alreadyConfigured)
|
||||
if (checkOnly && changed.length) throw Error('Installed definitions differ: ' + changed.map(plan => plan.agent.role).join(', '))
|
||||
if (changed.length) {
|
||||
backup = path.join(userData, 'capabilities/expert-backups', 'general-development-' + randomUUID())
|
||||
const backupRelative = path.relative(userData, backup)
|
||||
if (path.isAbsolute(backupRelative) || backupRelative.startsWith('..') || !backupRelative) throw Error('Backup path escaped YCode')
|
||||
await fs.mkdir(backup, { recursive: true })
|
||||
await ordinaryDirectory(backup)
|
||||
await fs.writeFile(path.join(backup, 'marketplace.before.json'), JSON.stringify(beforeMarket, null, 2), { flag: 'wx' })
|
||||
const snapshots = []
|
||||
for (const { agent, previous } of changed) {
|
||||
if (!previous) { snapshots.push({ id: agent.id, existed: false }); continue }
|
||||
const source = path.join(packageRoot, agent.id), target = path.join(backup, agent.id)
|
||||
const before = await inventory(source)
|
||||
await fs.cp(source, target, { recursive: true, errorOnExist: true, force: false })
|
||||
if (JSON.stringify(before) !== JSON.stringify(await inventory(target)) || JSON.stringify(before) !== JSON.stringify(await inventory(source))) throw Error('Expert changed while backing up: ' + agent.id)
|
||||
snapshots.push({ id: agent.id, existed: true, files: before })
|
||||
}
|
||||
await fs.writeFile(path.join(backup, 'backup.json'), JSON.stringify({ source: userData, snapshots }, null, 2), { flag: 'wx' })
|
||||
const completed = []
|
||||
for (const { agent, files, avatar, previous } of changed) {
|
||||
const owner = 'general-development-' + randomUUID()
|
||||
const draft = await service.expertPackageOperation(owner, { id: agent.id, action: 'read', refresh: true })
|
||||
if (JSON.stringify(draft.files) !== JSON.stringify(previous?.files ?? {})) throw Error('Expert changed after backup: ' + agent.id)
|
||||
const edits = Object.fromEntries(Object.keys(draft.files).filter(name => !(name in files) && !['.created-by-session', '.pending-preview-session'].includes(name)).map(name => [name, null]))
|
||||
await service.expertPackageOperation(owner, { id: agent.id, action: 'write', files: { ...edits, ...files } })
|
||||
const validation = await service.expertPackageOperation(owner, { id: agent.id, action: 'validate' })
|
||||
if (!validation.valid) throw Error(validation.errors.join('\n'))
|
||||
const result = await service.expertPackageOperation(owner, { id: agent.id, action: 'register' })
|
||||
if (!result.success) throw Error('Expert registration failed: ' + agent.id)
|
||||
// The text-only editing API preserves binaries but cannot add them; install our verified PNG separately.
|
||||
await installAvatar(path.join(packageRoot, agent.id), agent, avatar)
|
||||
completed.push(agent.id)
|
||||
await fs.writeFile(path.join(backup, 'applied.json'), JSON.stringify({ completed }, null, 2))
|
||||
}
|
||||
}
|
||||
const checks = []
|
||||
const listed = await service.listExperts()
|
||||
for (const { agent, files, avatar } of plans) {
|
||||
const document = await service.readExpert(agent.id)
|
||||
const binding = await service.activateExpert(agent.id)
|
||||
const turn = await service.expertTurnContext(agent.id)
|
||||
if (!listed.some(item => item.id === agent.id && !item.error) || binding.profession !== agent.profession.zh
|
||||
|| binding.name !== agent.displayName.zh || turn.display.profession !== agent.profession.zh) throw Error('Role activation mismatch: ' + agent.id)
|
||||
for (const [name, content] of Object.entries(files)) if (document.files[name] !== content) throw Error('Role text mismatch: ' + name)
|
||||
if (document.expert.skills?.length || turn.context.includes('<expert-skill path=') || turn.context.includes('<expert-skill-directory>')) throw Error('Specialist skill remains loaded: ' + agent.id)
|
||||
if (Object.keys(document.files).some(name => !(name in files) && name !== '.created-by-session')) throw Error('Unexpected specialist text remains: ' + agent.id)
|
||||
if (!turn.context.includes(document.prompt) || !agent.instructions.every(instruction => turn.context.includes(instruction))) throw Error('Role instructions did not reach turn context')
|
||||
if (document.expert.avatar !== avatar.url || binding.avatarUrl !== avatar.url) throw Error('Avatar did not reach the list/detail/activation data: ' + agent.id)
|
||||
checks.push({ id: agent.id, role: agent.role, profession: binding.profession, activated: true, filesMatched: Object.keys(files).length, startupSkills: 0,
|
||||
avatarBytes: avatar.bytes.length, avatarSha256: avatar.sha256 })
|
||||
}
|
||||
const afterMarket = JSON.parse(await fs.readFile(marketFile, 'utf8'))
|
||||
if (JSON.stringify(unrelated) !== JSON.stringify(afterMarket.plugins.filter(entry => !ids.has(entry.name)))) throw Error('Unrelated expert catalog entries changed')
|
||||
const report = { result: 'PASS', checkOnly, userData, backup, changed: changed.map(plan => plan.agent.id), checks,
|
||||
warnings: plans.flatMap(plan => plan.warnings.map(warning => ({ id: plan.agent.id, warning }))), messagesSent: 0 }
|
||||
await fs.writeFile(path.join(reports, 'report.json'), JSON.stringify(report, null, 2))
|
||||
return { ...report, reportDirectory: reports }
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const index = process.argv.indexOf('--user-data')
|
||||
if (index < 0 || !process.argv[index + 1]) throw Error('Pass --user-data <absolute YCode user-data path>')
|
||||
const report = await configure(process.argv[index + 1], { checkOnly: process.argv.includes('--check') })
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
}
|
||||
Reference in new issue
Block a user