// 技能推送工具 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> 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; 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() } }) })