mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(cli): add daemon mode via start --daemon, stop, and status commands
- Replace 6-subcommand daemon group with simpler top-level design: chatlab start --daemon install as system service (launchd/systemd) chatlab stop uninstall service and disable auto-start chatlab status show resident service status - Drop temporary background process layer; daemon = system service only - Store port/host meta in ~/.chatlab/daemon.json for status display - Fix --no-open description: Web UI still runs, only browser is suppressed - Update quick-start docs (cn/en/tw) to reflect new commands Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6cdc040ce8
commit
0e03295258
5 files changed
+377
-3
No files matched your search
@@ -286,7 +286,20 @@ program
|
||||
.option('--token <token>', 'Custom Bearer Token (reads from config or auto-generates if omitted)')
|
||||
.option('--headless', 'API-only mode, do not serve the Web UI')
|
||||
.option('--no-open', 'Do not auto-open the browser')
|
||||
.option('--daemon', 'Run as a resident system service (auto-start on login, macOS/Linux)')
|
||||
.action(async (options) => {
|
||||
// --daemon: install as system service and exit
|
||||
if (options.daemon) {
|
||||
const { serviceInstall } = await import('./daemon/service')
|
||||
serviceInstall({
|
||||
port: parseInt(options.port, 10),
|
||||
host: options.host,
|
||||
token: options.token || undefined,
|
||||
headless: options.headless,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { startHttpServer } = await import('./http')
|
||||
const port = parseInt(options.port, 10)
|
||||
|
||||
@@ -365,6 +378,37 @@ configCmd
|
||||
console.log(JSON.stringify(config, null, 2))
|
||||
})
|
||||
|
||||
program
|
||||
.command('stop')
|
||||
.description('Stop the resident service and remove auto-start (reverse of start --daemon)')
|
||||
.action(async () => {
|
||||
const { serviceUninstall } = await import('./daemon/service')
|
||||
serviceUninstall()
|
||||
})
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show resident service status')
|
||||
.action(async () => {
|
||||
const { getServiceStatus } = await import('./daemon/service')
|
||||
const svc = getServiceStatus()
|
||||
|
||||
console.log('\nChatLab Status')
|
||||
console.log('─'.repeat(36))
|
||||
|
||||
if (svc.installed) {
|
||||
const portStr = svc.port ? `http://${svc.host ?? '127.0.0.1'}:${svc.port}` : ''
|
||||
console.log(` Service: ${svc.running ? 'running' : 'installed (not running)'}`)
|
||||
if (portStr) console.log(` Address: ${portStr}`)
|
||||
console.log(` Auto-start: enabled`)
|
||||
console.log(`\n Use \`chatlab stop\` to remove the service.\n`)
|
||||
} else {
|
||||
console.log(` Service: not installed`)
|
||||
console.log(` Auto-start: disabled`)
|
||||
console.log(`\n Use \`chatlab start --daemon\` to install as a system service.\n`)
|
||||
}
|
||||
})
|
||||
|
||||
// --- 工具函数 ---
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* System service management — register ChatLab as a launchd (macOS) or
|
||||
* systemd --user (Linux) service for auto-start on login and crash recovery.
|
||||
*
|
||||
* macOS : ~/Library/LaunchAgents/fun.chatlab.daemon.plist
|
||||
* Linux : ~/.config/systemd/user/chatlab.service
|
||||
* Meta : ~/.chatlab/daemon.json { port, host, installedAt }
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { execSync } from 'child_process'
|
||||
import { resolveCliPath } from '../paths'
|
||||
|
||||
const SERVICE_LABEL = 'fun.chatlab.daemon'
|
||||
const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`)
|
||||
const SYSTEMD_DIR = path.join(os.homedir(), '.config', 'systemd', 'user')
|
||||
const SYSTEMD_SERVICE = path.join(SYSTEMD_DIR, 'chatlab.service')
|
||||
const SYSTEM_DIR = path.join(os.homedir(), '.chatlab')
|
||||
const LOG_FILE = path.join(SYSTEM_DIR, 'logs', 'daemon.log')
|
||||
const META_FILE = path.join(SYSTEM_DIR, 'daemon.json')
|
||||
|
||||
interface DaemonMeta {
|
||||
port: number
|
||||
host: string
|
||||
installedAt: string
|
||||
}
|
||||
|
||||
function writeMeta(meta: DaemonMeta): void {
|
||||
fs.mkdirSync(SYSTEM_DIR, { recursive: true })
|
||||
fs.writeFileSync(META_FILE, JSON.stringify(meta, null, 2))
|
||||
}
|
||||
|
||||
function readMeta(): DaemonMeta | null {
|
||||
try {
|
||||
if (!fs.existsSync(META_FILE)) return null
|
||||
return JSON.parse(fs.readFileSync(META_FILE, 'utf-8')) as DaemonMeta
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function removeMeta(): void {
|
||||
try {
|
||||
fs.unlinkSync(META_FILE)
|
||||
} catch {
|
||||
// already removed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ServiceInstallOptions {
|
||||
port?: number
|
||||
host?: string
|
||||
token?: string
|
||||
headless?: boolean
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
installed: boolean
|
||||
running: boolean
|
||||
port?: number
|
||||
host?: string
|
||||
}
|
||||
|
||||
// ── macOS (launchd) ───────────────────────────────────────────────────
|
||||
|
||||
function buildPlist(options: ServiceInstallOptions): string {
|
||||
const port = options.port ?? 3110
|
||||
const host = options.host ?? '127.0.0.1'
|
||||
const cliEntry = resolveCliPath('dist/cli.mjs')
|
||||
const args = ['start', '--no-open', '--port', String(port), '--host', host]
|
||||
if (options.token) args.push('--token', options.token)
|
||||
if (options.headless) args.push('--headless')
|
||||
|
||||
const programArgs = [process.execPath, cliEntry, ...args]
|
||||
.map((a) => ` <string>${a}</string>`)
|
||||
.join('\n')
|
||||
|
||||
fs.mkdirSync(path.join(SYSTEM_DIR, 'logs'), { recursive: true })
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${SERVICE_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${programArgs}
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LOG_FILE}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LOG_FILE}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
}
|
||||
|
||||
function getUid(): string {
|
||||
return execSync('id -u', { encoding: 'utf-8' }).trim()
|
||||
}
|
||||
|
||||
function launchctlLoad(plistPath: string): void {
|
||||
try {
|
||||
execSync(`launchctl bootstrap gui/${getUid()} "${plistPath}"`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
try {
|
||||
execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' })
|
||||
} catch (err) {
|
||||
throw new Error(`launchctl load failed: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function launchctlUnload(plistPath: string): void {
|
||||
try {
|
||||
execSync(`launchctl bootout gui/${getUid()}/${SERVICE_LABEL}`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
try {
|
||||
execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
// already unloaded, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installMacos(options: ServiceInstallOptions): void {
|
||||
fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true })
|
||||
|
||||
if (fs.existsSync(PLIST_PATH)) {
|
||||
launchctlUnload(PLIST_PATH)
|
||||
}
|
||||
|
||||
fs.writeFileSync(PLIST_PATH, buildPlist(options))
|
||||
launchctlLoad(PLIST_PATH)
|
||||
|
||||
const port = options.port ?? 3110
|
||||
const host = options.host ?? '127.0.0.1'
|
||||
writeMeta({ port, host, installedAt: new Date().toISOString() })
|
||||
|
||||
console.log(`\nChatLab is now running as a system service`)
|
||||
console.log(` API: http://${host}:${port}`)
|
||||
console.log(` Logs: ${LOG_FILE}`)
|
||||
console.log(` Service: ${SERVICE_LABEL} (launchd)`)
|
||||
console.log(`\nAuto-starts on login. Use \`chatlab stop\` to remove.\n`)
|
||||
}
|
||||
|
||||
function uninstallMacos(): void {
|
||||
if (!fs.existsSync(PLIST_PATH)) {
|
||||
console.log('No system service found.')
|
||||
removeMeta()
|
||||
return
|
||||
}
|
||||
launchctlUnload(PLIST_PATH)
|
||||
fs.unlinkSync(PLIST_PATH)
|
||||
removeMeta()
|
||||
console.log(`ChatLab system service removed.`)
|
||||
}
|
||||
|
||||
function statusMacos(): ServiceStatus {
|
||||
if (!fs.existsSync(PLIST_PATH)) return { installed: false, running: false }
|
||||
let running = false
|
||||
try {
|
||||
const out = execSync(`launchctl list | grep ${SERVICE_LABEL}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
}).trim()
|
||||
// launchctl list: PID Status Label — PID is '-' when not running
|
||||
running = out.length > 0 && !out.startsWith('-')
|
||||
} catch {
|
||||
running = false
|
||||
}
|
||||
const meta = readMeta()
|
||||
return { installed: true, running, port: meta?.port, host: meta?.host }
|
||||
}
|
||||
|
||||
// ── Linux (systemd --user) ────────────────────────────────────────────
|
||||
|
||||
function buildUnit(options: ServiceInstallOptions): string {
|
||||
const port = options.port ?? 3110
|
||||
const host = options.host ?? '127.0.0.1'
|
||||
const cliEntry = resolveCliPath('dist/cli.mjs')
|
||||
const args = ['start', '--no-open', '--port', String(port), '--host', host]
|
||||
if (options.token) args.push('--token', options.token)
|
||||
if (options.headless) args.push('--headless')
|
||||
const execStart = [process.execPath, cliEntry, ...args].join(' ')
|
||||
|
||||
return `[Unit]
|
||||
Description=ChatLab daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=${execStart}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${LOG_FILE}
|
||||
StandardError=append:${LOG_FILE}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`
|
||||
}
|
||||
|
||||
function installLinux(options: ServiceInstallOptions): void {
|
||||
fs.mkdirSync(SYSTEMD_DIR, { recursive: true })
|
||||
fs.mkdirSync(path.join(SYSTEM_DIR, 'logs'), { recursive: true })
|
||||
|
||||
fs.writeFileSync(SYSTEMD_SERVICE, buildUnit(options))
|
||||
|
||||
try {
|
||||
execSync('systemctl --user daemon-reload', { stdio: 'pipe' })
|
||||
execSync('systemctl --user enable --now chatlab.service', { stdio: 'pipe' })
|
||||
} catch (err) {
|
||||
throw new Error(`systemctl failed: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
const port = options.port ?? 3110
|
||||
const host = options.host ?? '127.0.0.1'
|
||||
writeMeta({ port, host, installedAt: new Date().toISOString() })
|
||||
|
||||
console.log(`\nChatLab is now running as a system service`)
|
||||
console.log(` API: http://${host}:${port}`)
|
||||
console.log(` Logs: ${LOG_FILE}`)
|
||||
console.log(` Unit: ${SYSTEMD_SERVICE} (systemd)`)
|
||||
console.log(`\nAuto-starts on login. Use \`chatlab stop\` to remove.\n`)
|
||||
}
|
||||
|
||||
function uninstallLinux(): void {
|
||||
if (!fs.existsSync(SYSTEMD_SERVICE)) {
|
||||
console.log('No system service found.')
|
||||
removeMeta()
|
||||
return
|
||||
}
|
||||
try {
|
||||
execSync('systemctl --user disable --now chatlab.service', { stdio: 'pipe' })
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
try {
|
||||
execSync('systemctl --user daemon-reload', { stdio: 'pipe' })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.unlinkSync(SYSTEMD_SERVICE)
|
||||
removeMeta()
|
||||
console.log(`ChatLab system service removed.`)
|
||||
}
|
||||
|
||||
function statusLinux(): ServiceStatus {
|
||||
if (!fs.existsSync(SYSTEMD_SERVICE)) return { installed: false, running: false }
|
||||
let running = false
|
||||
try {
|
||||
execSync('systemctl --user is-active --quiet chatlab.service', { stdio: 'pipe' })
|
||||
running = true
|
||||
} catch {
|
||||
running = false
|
||||
}
|
||||
const meta = readMeta()
|
||||
return { installed: true, running, port: meta?.port, host: meta?.host }
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
export function serviceInstall(options: ServiceInstallOptions): void {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return installMacos(options)
|
||||
case 'linux':
|
||||
return installLinux(options)
|
||||
default:
|
||||
console.error('Windows is not yet supported for daemon mode.')
|
||||
console.error('Please use `chatlab start` to run in the foreground instead.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export function serviceUninstall(): void {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return uninstallMacos()
|
||||
case 'linux':
|
||||
return uninstallLinux()
|
||||
default:
|
||||
console.error('Windows is not yet supported for daemon mode.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export function getServiceStatus(): ServiceStatus {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return statusMacos()
|
||||
case 'linux':
|
||||
return statusLinux()
|
||||
default:
|
||||
return { installed: false, running: false }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user