Преглед на файлове

新增 npm 命令行服务重启和版本查询

Samien преди 1 месец
родител
ревизия
0fe4a41889
променени са 4 файла, в които са добавени 143 реда и са изтрити 7 реда
  1. 2 0
      README.md
  2. 15 2
      bin/hermes-yaoyao.mjs
  3. 8 3
      docs/npm-install.md
  4. 118 2
      tests/serviceCli.test.ts

+ 2 - 0
README.md

@@ -60,6 +60,8 @@ yaoyao service install
 
 
 安装后访问 `http://127.0.0.1:15300` 创建管理员账号。macOS 通过 LaunchAgent 开机自启;Linux 的服务安装支持在路线图中。详见 [npm 安装说明](docs/npm-install.md)。
 安装后访问 `http://127.0.0.1:15300` 创建管理员账号。macOS 通过 LaunchAgent 开机自启;Linux 的服务安装支持在路线图中。详见 [npm 安装说明](docs/npm-install.md)。
 
 
+执行 `yaoyao service restart` 重启服务,执行 `yaoyao --version`(或 `yaoyao version` / `yaoyao -v`)查询已安装的 npm 包版本。
+
 ## Docker 安装
 ## Docker 安装
 
 
 远程镜像发布在 [Docker Hub](https://hub.docker.com/r/samienluo/yaoyao):
 远程镜像发布在 [Docker Hub](https://hub.docker.com/r/samienluo/yaoyao):

+ 15 - 2
bin/hermes-yaoyao.mjs

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 #!/usr/bin/env node
 
 
 import { execFileSync } from 'node:child_process'
 import { execFileSync } from 'node:child_process'
-import { existsSync, realpathSync } from 'node:fs'
+import { existsSync, readFileSync, realpathSync } from 'node:fs'
 import { chmod, mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
 import { chmod, mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
 import { homedir } from 'node:os'
 import { homedir } from 'node:os'
 import { dirname, join, resolve } from 'node:path'
 import { dirname, join, resolve } from 'node:path'
@@ -174,6 +174,13 @@ async function start() {
   process.stdout.write(`已启动 ${label}\n`)
   process.stdout.write(`已启动 ${label}\n`)
 }
 }
 
 
+async function restart() {
+  if (!existsSync(plistPath)) throw new Error('服务尚未安装,请先运行 yaoyao service install')
+  if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
+  await bootstrapLaunchAgent()
+  process.stdout.write(`已重启 ${label}\n`)
+}
+
 function stop() {
 function stop() {
   if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
   if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
   process.stdout.write(`已停止 ${label};Hermes 9119 未被操作\n`)
   process.stdout.write(`已停止 ${label};Hermes 9119 未被操作\n`)
@@ -218,15 +225,21 @@ async function pruneUploads(args) {
 
 
 async function main() {
 async function main() {
   const [, , group, command, ...args] = process.argv
   const [, , group, command, ...args] = process.argv
+  if (group === 'version' || group === '--version' || group === '-v') {
+    const { version } = JSON.parse(readFileSync(join(sourceProjectRoot, 'package.json'), 'utf8'))
+    process.stdout.write(`${version}\n`)
+    return
+  }
   if (group === 'service') {
   if (group === 'service') {
     if (command === 'install') return install()
     if (command === 'install') return install()
     if (command === 'start') return start()
     if (command === 'start') return start()
+    if (command === 'restart') return restart()
     if (command === 'stop') return stop()
     if (command === 'stop') return stop()
     if (command === 'status') return status()
     if (command === 'status') return status()
     if (command === 'uninstall') return uninstall()
     if (command === 'uninstall') return uninstall()
   }
   }
   if (group === 'uploads' && command === 'prune') return pruneUploads(args)
   if (group === 'uploads' && command === 'prune') return pruneUploads(args)
-  process.stdout.write(`夭夭 AI\n\n用法:\n  yaoyao service install|start|stop|status|uninstall\n  yaoyao uploads prune --older-than 30 --yes\n`)
+  process.stdout.write(`夭夭 AI\n\n用法:\n  yaoyao service install|start|restart|stop|status|uninstall\n  yaoyao version | --version | -v\n  yaoyao uploads prune --older-than 30 --yes\n`)
 }
 }
 
 
 export function isMainModule(entry = process.argv[1]) {
 export function isMainModule(entry = process.argv[1]) {

+ 8 - 3
docs/npm-install.md

@@ -30,10 +30,14 @@ yaoyao service install
 ```sh
 ```sh
 yaoyao service status     # 查看运行状态
 yaoyao service status     # 查看运行状态
 yaoyao service stop       # 停止服务
 yaoyao service stop       # 停止服务
-yaoyao service start      # 重新启动
+yaoyao service start      # 启动服务
+yaoyao service restart    # 重启服务;已停止的服务会重新启动
 yaoyao service uninstall  # 卸载服务(数据保留)
 yaoyao service uninstall  # 卸载服务(数据保留)
+yaoyao --version          # 查看已安装的 npm 包版本
 ```
 ```
 
 
+版本查询也支持 `yaoyao version` 和 `yaoyao -v`,无需安装或启动服务。`hermes-yaoyao` 是同一命令的别名,支持上述全部用法。
+
 ## 配置
 ## 配置
 
 
 | 环境变量 | 作用 | 默认 |
 | 环境变量 | 作用 | 默认 |
@@ -42,13 +46,14 @@ yaoyao service uninstall  # 卸载服务(数据保留)
 | `HERMES_YAOYAO_PORT` | Web 端口 | `15300` |
 | `HERMES_YAOYAO_PORT` | Web 端口 | `15300` |
 | `HERMES_YAOYAO_UPSTREAM` | Hermes 上游地址 | `http://127.0.0.1:9119` |
 | `HERMES_YAOYAO_UPSTREAM` | Hermes 上游地址 | `http://127.0.0.1:9119` |
 
 
-LaunchAgent 安装时的环境会写入 plist;修改配置后执行 `yaoyao service stop && yaoyao service start` 生效。
+LaunchAgent 安装时的环境会写入 plist;修改环境变量配置后,执行 `yaoyao service install` 重新写入配置并启动服务。`yaoyao service restart` 使用已保存的配置重启。
 
 
 ## 升级
 ## 升级
 
 
 ```sh
 ```sh
 npm update -g @lsamien/yaoyao
 npm update -g @lsamien/yaoyao
-yaoyao service stop && yaoyao service start
+yaoyao service restart
+yaoyao --version
 ```
 ```
 
 
 Web 界面中的「系统更新」入口面向源码/发布包安装;npm 安装的服务请直接用上述 npm 命令升级,避免两套版本并存。
 Web 界面中的「系统更新」入口面向源码/发布包安装;npm 安装的服务请直接用上述 npm 命令升级,避免两套版本并存。

+ 118 - 2
tests/serviceCli.test.ts

@@ -1,9 +1,125 @@
-import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { homedir, tmpdir } from 'node:os'
 import { homedir, tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { bootstrapLaunchAgent, isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
 import { bootstrapLaunchAgent, isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
 
 
+describe('npm-installed CLI commands', () => {
+  const label = 'com.samien.hermes-yaoyao'
+  const domain = `gui/${process.getuid?.() ?? 501}`
+  const entry = join(process.cwd(), 'bin', 'hermes-yaoyao.mjs')
+  const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
+  let root: string
+  let plist: string
+
+  beforeEach(() => {
+    root = mkdtempSync(join(tmpdir(), 'yaoyao-npm-cli-'))
+    const bin = join(root, 'bin')
+    mkdirSync(bin)
+    const launchAgents = join(root, 'Library', 'LaunchAgents')
+    mkdirSync(launchAgents, { recursive: true })
+    plist = join(launchAgents, `${label}.plist`)
+    for (const name of ['yaoyao', 'hermes-yaoyao']) symlinkSync(entry, join(bin, name))
+    writeFileSync(join(bin, 'launchctl'), `#!${process.execPath}
+import { appendFileSync } from 'node:fs'
+const args = process.argv.slice(2)
+appendFileSync(process.env.YAOYAO_CLI_TEST_LOG, JSON.stringify(args) + '\\n')
+if (args[0] === process.env.YAOYAO_CLI_TEST_FAIL) {
+  process.stderr.write('fixture launchctl failure\\n')
+  process.exit(1)
+}
+if (args[0] === 'print' && process.env.YAOYAO_CLI_TEST_LOADED !== '1') process.exit(1)
+`, { mode: 0o755 })
+  })
+
+  afterEach(() => { rmSync(root, { recursive: true, force: true }) })
+
+  function runCli(args: string[], env: NodeJS.ProcessEnv = {}, name = 'yaoyao') {
+    return spawnSync(process.execPath, [join(root, 'bin', name), ...args], {
+      cwd: root,
+      encoding: 'utf8',
+      timeout: 5_000,
+      env: {
+        ...process.env,
+        HOME: root,
+        PATH: `${join(root, 'bin')}:${process.env.PATH}`,
+        YAOYAO_HOME: join(root, 'data'),
+        HERMES_YAOYAO_SERVICE_ROOT: join(root, 'other-runtime'),
+        YAOYAO_CLI_TEST_LOG: join(root, 'launchctl.log'),
+        ...env,
+      },
+    })
+  }
+
+  function calls(): string[][] {
+    const log = join(root, 'launchctl.log')
+    return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : []
+  }
+
+  it.each(['version', '--version', '-v'])('queries the package version through both npm bin aliases: %s', command => {
+    for (const name of ['yaoyao', 'hermes-yaoyao']) {
+      const result = runCli([command], {}, name)
+      expect(result.status).toBe(0)
+      expect(result.stdout).toBe(`${version}\n`)
+      expect(result.stderr).toBe('')
+    }
+    expect(calls()).toEqual([])
+    expect(existsSync(join(root, 'data'))).toBe(false)
+  })
+
+  it.each(['yaoyao', 'hermes-yaoyao'])('restarts the installed service through %s while preserving its configuration and data', name => {
+    writeFileSync(plist, 'saved service configuration')
+    mkdirSync(join(root, 'data'))
+    const data = join(root, 'data', 'keep.txt')
+    writeFileSync(data, 'saved user data')
+
+    const result = runCli(['service', 'restart'], { YAOYAO_CLI_TEST_LOADED: '1' }, name)
+
+    expect(result.status).toBe(0)
+    expect(result.stdout).toBe(`已重启 ${label}\n`)
+    expect(calls()).toEqual([
+      ['print', `${domain}/${label}`],
+      ['bootout', `${domain}/${label}`],
+      ['bootstrap', domain, plist],
+    ])
+    expect(readFileSync(plist, 'utf8')).toBe('saved service configuration')
+    expect(readFileSync(data, 'utf8')).toBe('saved user data')
+  })
+
+  it('starts an installed service that was stopped', () => {
+    writeFileSync(plist, 'saved service configuration')
+
+    const result = runCli(['service', 'restart'])
+
+    expect(result.status).toBe(0)
+    expect(calls()).toEqual([['print', `${domain}/${label}`], ['bootstrap', domain, plist]])
+  })
+
+  it('explains how to install a missing service without invoking launchctl', () => {
+    const result = runCli(['service', 'restart'])
+
+    expect(result.status).toBe(1)
+    expect(result.stderr).toContain('请先运行 yaoyao service install')
+    expect(result.stdout).toBe('')
+    expect(calls()).toEqual([])
+  })
+
+  it.each(['bootout', 'bootstrap'])('reports a failed %s without reporting restart success', command => {
+    writeFileSync(plist, 'saved service configuration')
+
+    const result = runCli(['service', 'restart'], {
+      YAOYAO_CLI_TEST_LOADED: '1',
+      YAOYAO_CLI_TEST_FAIL: command,
+    })
+
+    expect(result.status).toBe(1)
+    expect(result.stdout).toBe('')
+    expect(result.stderr).toContain('fixture launchctl failure')
+    expect(calls().at(-1)?.[0]).toBe(command)
+  })
+})
+
 describe('hermes-yaoyao LaunchAgent plist', () => {
 describe('hermes-yaoyao LaunchAgent plist', () => {
   it('includes the local Hermes CLI directory in PATH', () => {
   it('includes the local Hermes CLI directory in PATH', () => {
     const plist = launchAgentPlist()
     const plist = launchAgentPlist()