| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- // 技能推送工具 agent-skills(docs/design/19-skill-cli.md):对测试服务端到端运行命令;浏览器登录(设备码授权)的服务端规则
- import fs from 'node:fs'
- import os from 'node:os'
- import path from 'node:path'
- import pino from 'pino'
- import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
- import { main, type Io } from '../../../packages/skills-cli/src/main'
- import { isNewer, npxArgs } from '../../../packages/skills-cli/src/update'
- import { createApp, type App } from '../src/app'
- import { DEVICE_LOGIN } from '../src/custom-skills/device-login'
- import { buildServer } from '../src/http/server'
- let app: App
- let server: Awaited<ReturnType<typeof buildServer>>
- let base: string
- let cfgDir: string
- let project: string
- const md = (name: string, extra = '') => `---\nname: ${name}\ndescription: ${name} 的说明:处理某类业务数据的流程与规范\n${extra}---\n# ${name}\n`
- /** 运行一条命令;onSleep 在每次等待时调用(模拟管理员在浏览器中操作) */
- async function run(args: string[], opts: { env?: Record<string, string>; cwd?: string; onSleep?: () => void; stdin?: string; version?: string; relaunch?: Io['relaunch'] } = {}) {
- const out: string[] = []
- const err: string[] = []
- const io: Io = {
- out: s => void out.push(s), err: s => void err.push(s),
- env: { AGENT_SKILLS_CONFIG_DIR: cfgDir, ...opts.env }, cwd: opts.cwd ?? project, tty: false,
- openUrl: async () => false,
- sleep: async () => {
- opts.onSleep?.()
- await new Promise(r => setTimeout(r, 20))
- },
- readStdin: async () => opts.stdin ?? '',
- prompt: async () => '',
- relaunch: opts.relaunch ?? (async () => null),
- version: opts.version,
- }
- const code = await main(args, io)
- return { code, out: out.join('\n'), err: err.join('\n') }
- }
- const pending = (name: string) => app.skillRegistry.listItems('pending').find(i => i.name === name)
- beforeAll(async () => {
- const log = pino({ level: 'silent' })
- app = createApp(log, { providers: {} })
- server = await buildServer(app, { logger: log })
- base = await server.listen({ port: 0, host: '127.0.0.1' })
- cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-cfg-'))
- project = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-proj-'))
- for (const n of ['alpha', 'beta']) {
- fs.mkdirSync(path.join(project, 'skills', n, 'references'), { recursive: true })
- fs.writeFileSync(path.join(project, 'skills', n, 'SKILL.md'), md(n))
- fs.writeFileSync(path.join(project, 'skills', n, 'references', 'guide.md'), `${n} v1\n`)
- }
- fs.writeFileSync(path.join(project, 'skills', 'alpha', '.env'), 'TOKEN=abc')
- DEVICE_LOGIN.intervalSec = 1
- })
- afterAll(async () => {
- await server.close()
- })
- describe('推送工具:浏览器登录', () => {
- it('login:显示验证码,管理员批准后保存密钥(权限 600);whoami', async () => {
- let approved = false
- const r = await run(['login', '--server', base], {
- onSleep: () => {
- const l = app.skillDeviceLogins.pending()[0]
- if (l && !approved) {
- expect(l.client.user).toBe(os.userInfo().username)
- app.skillDeviceLogins.approve(l.userCode, { name: '张三 · 本机', expiresInDays: 30 })
- approved = true
- }
- },
- })
- expect(r.code).toBe(0)
- expect(r.err).toMatch(/验证码:[A-Z]{4}-[A-Z]{4}/)
- expect(r.err).toContain(`${base}/admin/skill-login?code=`)
- expect(r.out).toContain('已登录')
- const file = path.join(cfgDir, 'credentials.json')
- expect(fs.statSync(file).mode & 0o777).toBe(0o600)
- const creds = JSON.parse(fs.readFileSync(file, 'utf8'))
- expect(creds.default).toBe(base)
- expect(creds.servers[base].token).toMatch(/^sk_pub_/)
- expect(app.skillRegistry.listKeys().find(k => k.name === '张三 · 本机')).toMatchObject({ origin: 'device' })
- const who = await run(['whoami'])
- expect(who.code).toBe(0)
- expect(who.out).toContain('张三 · 本机')
- expect(who.out).toContain('浏览器登录')
- })
- it('服务端规则:拒绝、询问过快、设备码只能换取一次、每个 IP 的申请次数', () => {
- const d = app.skillDeviceLogins.start({ host: 'h', user: 'u' }, '10.0.0.1', 'https://x')
- expect(d.verificationUriComplete).toBe(`https://x/admin/skill-login?code=${d.userCode}`)
- expect(() => app.skillDeviceLogins.poll(d.deviceCode)).toThrow(/等待/)
- app.skillDeviceLogins.deny(d.userCode.toLowerCase().replace('-', ' '))
- expect(() => app.skillDeviceLogins.poll(d.deviceCode)).toThrow(/拒绝/)
- expect(() => app.skillDeviceLogins.approve(d.userCode, { name: 'x', expiresInDays: 1 })).toThrow(/处理过/)
- const saved = DEVICE_LOGIN.intervalSec
- DEVICE_LOGIN.intervalSec = 5
- try {
- const e = app.skillDeviceLogins.start({}, '10.0.0.2', 'https://x')
- expect(() => app.skillDeviceLogins.poll(e.deviceCode)).toThrow(/等待/)
- expect(() => app.skillDeviceLogins.poll(e.deviceCode)).toThrow(/过于频繁/)
- } finally {
- DEVICE_LOGIN.intervalSec = saved
- }
- const f = app.skillDeviceLogins.start({}, '10.0.0.3', 'https://x')
- app.skillDeviceLogins.approve(f.userCode, { name: '一次性', expiresInDays: null })
- expect(app.skillDeviceLogins.poll(f.deviceCode)).toMatchObject({ name: '一次性', expiresAt: null })
- expect(() => app.skillDeviceLogins.poll(f.deviceCode)).toThrow(/过期/)
- expect(app.skillDeviceLogins.get(f.userCode).status).toBe('consumed')
- for (let i = 0; i < DEVICE_LOGIN.perIp; i++) app.skillDeviceLogins.start({}, '10.0.0.9', 'https://x')
- expect(() => app.skillDeviceLogins.start({}, '10.0.0.9', 'https://x')).toThrow(/频繁/)
- })
- })
- describe('推送工具:推送与查询', () => {
- it('init 生成不含密钥的项目配置;push --dry-run 只校验;本地预检不通过时不推送', async () => {
- expect((await run(['init'])).code).toBe(0)
- const cfg = JSON.parse(fs.readFileSync(path.join(project, '.agent-skills.json'), 'utf8'))
- expect(cfg).toEqual({ server: base, paths: ['skills'], exclude: [] })
- expect(JSON.stringify(cfg)).not.toContain('sk_pub_')
- const dry = await run(['push', '--dry-run'])
- expect(dry.code).toBe(0)
- expect(dry.out).toContain('试运行')
- expect(dry.out).toContain('.env(已排除)')
- expect(app.skillRegistry.pendingCount()).toBe(0)
- fs.writeFileSync(path.join(project, 'skills', 'beta', 'SKILL.md'), '---\nname: beta\n---\n')
- const bad = await run(['push'])
- expect(bad.code).toBe(1)
- expect(bad.out).toContain('缺少 description')
- expect(app.skillRegistry.pendingCount()).toBe(0)
- fs.writeFileSync(path.join(project, 'skills', 'beta', 'SKILL.md'), md('beta'))
- })
- it('push:在子目录中执行也按项目配置找技能;输出审核链接;status 查看结果', async () => {
- const r = await run(['push', '-m', '首次推送'], { cwd: path.join(project, 'skills', 'alpha') })
- expect(r.code).toBe(0)
- expect(r.out).toContain('已推送 sub_')
- expect(r.out).toMatch(/alpha {2}待审核(新技能)/)
- const item = pending('alpha')!
- expect(r.out).toContain(`${base}/admin/plugins?tab=custom&review=${item.id}`)
- expect(item).toMatchObject({ message: '首次推送', keyName: '张三 · 本机' })
- expect(item.meta.cli).toBe('dev')
- expect(app.skillRegistry.item(item.id).files.map(f => f.path)).toEqual(['SKILL.md', 'references/guide.md'])
- const st = await run(['status'])
- expect(st.out).toMatch(/alpha(待审核)、beta(待审核)/)
- })
- it('push --wait:批准后退出码 0;被拒绝为 4;超时仍在待审核为 5', async () => {
- fs.writeFileSync(path.join(project, 'skills', 'alpha', 'references', 'guide.md'), 'alpha v2\n')
- const ok = await run(['push', '--only', 'alpha', '--wait'], {
- onSleep: () => {
- const i = pending('alpha')
- if (i) app.skillRegistry.approve(i.id, { action: 'create', name: 'alpha', publish: true })
- },
- })
- expect(ok.code).toBe(0)
- expect(ok.out).toContain('审核结果')
- expect(ok.out).toContain('alpha 已批准:新建 alpha v1,已发布')
- const rejected = await run(['push', '--only', 'beta', '--wait'], {
- onSleep: () => {
- const i = pending('beta')
- if (i) app.skillRegistry.reject(i.id, '描述不清楚')
- },
- })
- expect(rejected.code).toBe(4)
- expect(rejected.out).toContain('拒绝原因:描述不清楚')
- const timeout = await run(['push', '--only', 'beta', '--wait', '--timeout', '0.2'])
- expect(timeout.code).toBe(5)
- expect(timeout.out).toContain('仍在待审核')
- // 撤回
- const id = /已推送 (sub_\w+)/.exec(timeout.out)![1]!
- const w = await run(['withdraw', id])
- expect(w.code).toBe(0)
- expect(w.out).toContain('beta 已撤回')
- })
- it('CI:用环境变量中的密钥推送;--strict 时疑似密钥为错误;无效密钥退出码 2', async () => {
- const { key } = app.skillRegistry.createKey({ name: 'fwh-app · CI', description: '', expiresInDays: 365 })
- const ci = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-ci-'))
- fs.mkdirSync(path.join(ci, 'gamma'))
- fs.writeFileSync(path.join(ci, 'gamma', 'SKILL.md'), md('gamma'))
- fs.writeFileSync(path.join(ci, 'gamma', 'run.sh'), 'curl -u admin:password123@ https://x\npassword = abcdefghijklmnop1234\n')
- const env = { AGENT_SKILLS_TOKEN: key, AGENT_SKILLS_SERVER: base, AGENT_SKILLS_CONFIG_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-empty-')) }
- const strict = await run(['push', '--strict'], { cwd: ci, env })
- expect(strict.code).toBe(1)
- expect(strict.out).toContain('run.sh 可能包含密码或密钥赋值')
- const r = await run(['push', '--json'], { cwd: ci, env })
- expect(r.code).toBe(0)
- const sub = JSON.parse(r.out)
- expect(sub.items[0]).toMatchObject({ name: 'gamma', status: 'pending' })
- expect(pending('gamma')!.keyName).toBe('fwh-app · CI')
- const bad = await run(['push'], { cwd: ci, env: { ...env, AGENT_SKILLS_TOKEN: 'sk_pub_wrong' } })
- expect(bad.code).toBe(2)
- expect(bad.err).toContain('无效')
- })
- it('logout:在服务器上停用密钥并删除本机保存的密钥', async () => {
- const r = await run(['logout'])
- expect(r.code).toBe(0)
- expect(r.out).toContain('已在服务器上停用密钥:张三 · 本机')
- expect(app.skillRegistry.listKeys().find(k => k.name === '张三 · 本机')!.enabled).toBe(false)
- expect(JSON.parse(fs.readFileSync(path.join(cfgDir, 'credentials.json'), 'utf8')).servers).toEqual({})
- expect((await run(['whoami'])).code).toBe(2)
- expect((await run(['bogus'])).code).toBe(1)
- expect((await run(['--help'])).out).toContain('退出码')
- })
- })
- describe('推送工具:自动更新', () => {
- it('服务器上有新版本时用带版本号的包重新执行同一条命令;版本相同、关闭检查或查询失败时继续用当前版本', async () => {
- expect(isNewer('0.10.0', '0.9.9')).toBe(true)
- expect(isNewer('0.2.0', '0.2.0')).toBe(false)
- expect(isNewer('1.0.0', 'dev')).toBe(false)
- const real = globalThis.fetch
- let latest: string | null = '0.3.0'
- const spy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
- if (!String(input).endsWith('/npm/@agent-server%2fskills-cli')) return real(input, init)
- return Promise.resolve(latest ? Response.json({ name: '@agent-server/skills-cli', 'dist-tags': { latest } }) : new Response('', { status: 404 }))
- })
- try {
- const calls: string[][] = []
- const relaunch: Io['relaunch'] = async (release, argv) => {
- calls.push(npxArgs(release, argv))
- return 7
- }
- const up = await run(['whoami', '--server', base], { version: '0.2.0', relaunch })
- expect(up.code).toBe(7)
- expect(up.err).toContain('新版本 0.3.0(当前 0.2.0)')
- expect(calls).toEqual([['-y', '--registry', `${base}/npm/`, '@agent-server/skills-cli@0.3.0', 'whoami', '--server', base]])
- // 无法重新执行时继续用当前版本(此时没有登录,whoami 退出码 2)
- const failed = await run(['whoami', '--server', base], { version: '0.2.0', relaunch: async () => null })
- expect(failed.code).toBe(2)
- expect(failed.err).toContain('无法自动更新')
- calls.length = 0
- expect((await run(['whoami', '--server', base], { version: '0.3.0', relaunch })).code).toBe(2)
- expect((await run(['whoami', '--server', base], { version: '0.2.0', relaunch, env: { AGENT_SKILLS_NO_UPDATE_CHECK: '1' } })).code).toBe(2)
- expect((await run(['whoami', '--server', base], { relaunch })).code).toBe(2) // 开发版本
- latest = null
- expect((await run(['whoami', '--server', base], { version: '0.2.0', relaunch })).code).toBe(2)
- expect(calls).toEqual([])
- } finally {
- spy.mockRestore()
- }
- })
- })
|