@alilis/k-hat 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -126
- package/dist/{cli.js → cli/cli.js} +54 -9
- package/dist/{probe.js → core/probe.js} +1 -1
- package/dist/{key-protector.js → data/key-protector.js} +1 -1
- package/dist/{portable-vault.js → data/portable-vault.js} +2 -2
- package/dist/{store.js → data/store.js} +2 -2
- package/dist/{vault.js → data/vault.js} +1 -1
- package/dist/{admin.js → network/admin.js} +8 -8
- package/dist/{server.js → network/server.js} +21 -11
- package/dist/service.js +283 -0
- package/package.json +4 -4
- /package/dist/{config.js → core/config.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{router.js → core/router.js} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
- /package/dist/{dpapi.js → data/dpapi.js} +0 -0
- /package/dist/{selector.js → network/selector.js} +0 -0
- /package/dist/{tui-client.js → ui/tui/tui-client.js} +0 -0
- /package/dist/{tui-main.js → ui/tui/tui-main.js} +0 -0
- /package/dist/{tui-state.js → ui/tui/tui-state.js} +0 -0
- /package/dist/{tui-types.js → ui/tui/tui-types.js} +0 -0
- /package/dist/{tui.js → ui/tui/tui.js} +0 -0
- /package/dist/{web-ui.js → ui/web-ui.js} +0 -0
package/README.md
CHANGED
|
@@ -1,82 +1,46 @@
|
|
|
1
|
-
# K-Hat
|
|
1
|
+
# K-Hat
|
|
2
2
|
|
|
3
|
-
本地 API key 代理管理器,统一管理多个 AI 服务商的密钥并自动轮转。
|
|
3
|
+
本地 API key 代理管理器,统一管理多个 AI 服务商的密钥并自动轮转。K-Hat 在本机提供兼容 OpenAI 的 API 入口,将请求按模型路由到已配置的 Provider,并在 key 不可用时自动切换。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 安装与首次运行
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
要求 Node.js 20 或更高版本。
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **后台守护与自愈**:`khat start` 在后台启动 supervisor,崩溃后指数退避重启;`khat stop` 优雅停止,`--foreground` 用于诊断
|
|
16
|
-
- **可观测性**:零机密 JSONL 请求日志、按 key 的请求/失败/字节/Token 计数器、`khat log` 与 `/_keys/logs`
|
|
17
|
-
- **本地管理页**:`khat ui` 以 60 秒单次票据打开内嵌 UI,查看状态、日志并恢复不可用 key
|
|
18
|
-
- **零运行时依赖**:纯 Node.js/TypeScript,仅用 Node 内置模块
|
|
19
|
-
|
|
20
|
-
## 安装
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm link
|
|
12
|
+
khat init
|
|
13
|
+
khat start
|
|
14
|
+
```
|
|
21
15
|
|
|
22
|
-
|
|
16
|
+
如果不希望创建全局命令,也可以在仓库内运行:
|
|
23
17
|
|
|
24
18
|
```bash
|
|
25
|
-
npm
|
|
19
|
+
npm run khat -- init
|
|
20
|
+
npm run khat -- start
|
|
26
21
|
```
|
|
27
22
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
> Windows 下若提示找不到 `khat`,确认 `%APPDATA%\npm` 在 PATH 中(重开终端后生效)。
|
|
23
|
+
初始化后,使用 Provider、Key 和 Route 命令完成配置。访问令牌可通过 `khat token show` 查看,建议仅在需要配置客户端时使用。
|
|
31
24
|
|
|
32
|
-
|
|
25
|
+
## 接入客户端
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
git clone https://github.com/aweirdocc/k-hat
|
|
36
|
-
cd k-hat
|
|
37
|
-
npm install # 安装依赖的同时会自动编译 TypeScript(prepare 钩子)
|
|
38
|
-
```
|
|
27
|
+
将客户端的 `base_url` 指向 `http://127.0.0.1:8787/v1`(或 `http://127.0.0.1:8787`,视工具而定),认证令牌填入 `khat init` 生成的访问令牌。
|
|
39
28
|
|
|
40
|
-
|
|
29
|
+
## 停止后台代理
|
|
41
30
|
|
|
42
31
|
```bash
|
|
43
|
-
|
|
44
|
-
npm run khat -- tui
|
|
32
|
+
khat stop
|
|
45
33
|
```
|
|
46
34
|
|
|
47
|
-
|
|
35
|
+
### 服务化部署(M7)
|
|
48
36
|
|
|
49
37
|
```bash
|
|
50
|
-
#
|
|
51
|
-
khat
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
# 2. 添加 Provider
|
|
55
|
-
khat provider add deepseek --base-url https://api.deepseek.com --name DeepSeek --protocol openai
|
|
56
|
-
|
|
57
|
-
# 3. 添加 key(省略 --value 时交互式提示输入;--weight 为该 key 的轮询权重,默认 1)
|
|
58
|
-
khat key add deepseek main --weight 3
|
|
59
|
-
|
|
60
|
-
# 4. 添加路由:模型名 -> Provider
|
|
61
|
-
khat route add deepseek-v4-pro deepseek
|
|
62
|
-
|
|
63
|
-
# 5. 后台启动代理(管理变更命令也会在需要时自动启动它)
|
|
64
|
-
khat start
|
|
65
|
-
|
|
66
|
-
# 6. 查看状态
|
|
67
|
-
khat status
|
|
68
|
-
|
|
69
|
-
# 7. 打开本地管理页
|
|
70
|
-
khat ui
|
|
71
|
-
|
|
72
|
-
# 或在终端中打开交互式管理界面
|
|
73
|
-
khat tui
|
|
74
|
-
|
|
75
|
-
# 8. 停止后台代理
|
|
76
|
-
khat stop
|
|
38
|
+
khat service install # 注册开机自启(Windows 计划任务 / macOS launchd / Linux systemd --user)
|
|
39
|
+
khat service uninstall # 取消开机自启
|
|
40
|
+
khat service status # 查看服务注册状态与守护进程运行状态
|
|
77
41
|
```
|
|
78
42
|
|
|
79
|
-
|
|
43
|
+
> **注意**:service 安装时会固定 `KHAT_HOME` 为安装时解析的数据目录;`khat stop` 不会卸载服务,下次登录仍自动启动;Windows 上未采用 NSSM(因用户级 DPAPI vault 无法以 LocalSystem 运行)。
|
|
80
44
|
|
|
81
45
|
## CLI 命令
|
|
82
46
|
|
|
@@ -97,6 +61,28 @@ khat stop
|
|
|
97
61
|
|
|
98
62
|
> Provider、key 和路由的新增、更新、删除与恢复等管理变更通过本地 Admin API 执行;若 daemon 尚未运行,命令会自动启动它。
|
|
99
63
|
|
|
64
|
+
### 模型列表接口
|
|
65
|
+
|
|
66
|
+
代理提供带访问令牌认证的只读模型列表接口:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
TOKEN=$(khat token show)
|
|
70
|
+
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8787/models
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`GET /models` 和兼容 OpenAI 客户端的 `GET /v1/models` 返回当前配置中启用的 `routes`,不会请求上游 Provider,也不会返回 `upstreamModel`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"object": "list",
|
|
78
|
+
"data": [
|
|
79
|
+
{ "id": "deepseek-chat", "object": "model", "created": 0, "owned_by": "deepseek" }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
未配置路由时 `data` 为空;禁用路由不会出现在列表中。请求必须携带 `Authorization: Bearer <access-token>`,否则返回 HTTP 401。
|
|
85
|
+
|
|
100
86
|
### 访问令牌
|
|
101
87
|
|
|
102
88
|
| 命令 | 说明 |
|
|
@@ -124,7 +110,7 @@ khat stop
|
|
|
124
110
|
| `khat key list [<provider>]` | 列出 key(默认脱敏,仅显示尾 4 位) |
|
|
125
111
|
| `khat key remove <provider> <id>` | 删除 key |
|
|
126
112
|
| `khat key enable <provider> <id>` | 将被标记不可用的 key 恢复为可用 |
|
|
127
|
-
| `khat key test <provider> <id> [--model <m>]` |
|
|
113
|
+
| `khat key test <provider> <id> [--model <m>]` | 直连配置的上游发送一次最小请求探活;成功后自动恢复 key,不会仅因探测返回 401/402/429 就标记 key 不可用 |
|
|
128
114
|
|
|
129
115
|
### 路由管理
|
|
130
116
|
|
|
@@ -140,7 +126,7 @@ khat stop
|
|
|
140
126
|
| 变量 | 说明 |
|
|
141
127
|
|---|---|
|
|
142
128
|
| `KHAT_HOME` | 数据目录(默认 `~/.khat`) |
|
|
143
|
-
| `KHAT_ACCESS_TOKEN` |
|
|
129
|
+
| `KHAT_ACCESS_TOKEN` | 覆盖密钥库中的访问密钥(主要供测试) |
|
|
144
130
|
|
|
145
131
|
## 常见异常与处理
|
|
146
132
|
|
|
@@ -162,68 +148,7 @@ khat stop
|
|
|
162
148
|
| `daemon started but health check failed` | 后台 daemon 启动异常 | `khat start --foreground` 前台运行观察日志 |
|
|
163
149
|
| `--weight must be a positive integer` | 权重不是正整数 | 传入 ≥1 的整数 |
|
|
164
150
|
| `unknown provider: <x>` / `unknown key: <x/y>` | 引用了不存在的 Provider / Key | 用 `khat provider list` / `khat key list` 核对 |
|
|
165
|
-
| `
|
|
166
|
-
|
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
|
|
170
|
-
### 代理返回的入口错误
|
|
171
|
-
|
|
172
|
-
| 状态码 | 含义 | 处理 |
|
|
173
|
-
|---|---|---|
|
|
174
|
-
| 400 | 入口路径与 Provider 协议不匹配,或请求体非法 | 检查工具配置的入口路径与 `base_url` |
|
|
175
|
-
| 401 | 访问令牌无效 | 用 `khat token show` 核对工具里配置的令牌 |
|
|
176
|
-
| 404 | 请求的模型没有匹配的路由 | `khat route add <model> <provider>` |
|
|
177
|
-
| 502 | 上游返回错误(如 key 探活失败) | 查看 `khat log` 与 key 状态 |
|
|
178
|
-
|
|
179
|
-
### 使用注意事项
|
|
180
|
-
|
|
181
|
-
- **变更命令会自动拉起 daemon**:`provider / key / route` 等管理命令通过本地 Admin API 执行,若后台服务未运行会自动启动(单写者设计),无需手动 `khat start`。
|
|
182
|
-
- **UI 票据单次有效**:`khat ui` 签发的票据 60 秒有效、只能交换一次会话;管理页保持打开不受影响,重新打开需再次 `khat ui`。
|
|
183
|
-
- **密钥库加密描述**:`vault.json` 是合法 JSON 信封,机密载荷经 AES-256-GCM 加密,32 字节主密钥由 Windows DPAPI 保护;文件本身不是整体二进制密文。
|
|
184
|
-
|
|
185
|
-
## 协议支持
|
|
186
|
-
|
|
187
|
-
| 入口路径 | 协议 | 认证头 | 服务的工具 |
|
|
188
|
-
|---|---|---|---|
|
|
189
|
-
| `/v1/chat/completions` | OpenAI Chat | `Authorization: Bearer` | OpenCode、Cursor(部分) |
|
|
190
|
-
| `/v1/responses` | OpenAI Responses | `Authorization: Bearer` | Codex |
|
|
191
|
-
| `/v1/messages` | Anthropic Messages | `x-api-key` + `anthropic-version` | ZCode |
|
|
192
|
-
|
|
193
|
-
采用**同族直通**策略:OpenAI 入口转发到 OpenAI 上游,Anthropic 入口转发到 Anthropic 上游,只重写认证头与目标 URL,不改写请求体。入口路径与 Provider 协议不匹配时返回 400。
|
|
194
|
-
|
|
195
|
-
## 数据安全
|
|
196
|
-
|
|
197
|
-
- **密钥库加密落盘**:`vault.json` 是 JSON 信封;其中 `payload` 使用 AES-256-GCM 加密,32 字节主密钥由 Windows DPAPI(CurrentUser 作用域)保护,key 明文仅存在于守护进程内存
|
|
198
|
-
- **配置零机密**:`config.json` 只存 `vaultRef` 引用,可放心进 Git/网盘备份
|
|
199
|
-
- **日志零机密**:不记录 key 明文、请求/响应体、认证头;CLI 展示 key 默认脱敏
|
|
200
|
-
- **入口双重防护**:所有请求校验 256-bit 访问令牌;默认只绑定 loopback,不对局域网暴露
|
|
201
|
-
- **上游强制 HTTPS**:证书校验,不提供禁用开关(仅 loopback 主机允许明文 http)
|
|
202
|
-
|
|
203
|
-
## 项目结构
|
|
204
|
-
|
|
205
|
-
```
|
|
206
|
-
src/
|
|
207
|
-
├── server.ts # HTTP 服务与转发器(双协议、认证头重写、超时、重试)
|
|
208
|
-
├── router.ts # 模型名 -> Provider 路由
|
|
209
|
-
├── selector.ts # 平滑加权轮询
|
|
210
|
-
├── store.ts # config/state/vault 存储层 + CLI 变更操作
|
|
211
|
-
├── vault.ts # AES-256-GCM 加密密钥库
|
|
212
|
-
├── config.ts # 配置加载、校验、原子写入
|
|
213
|
-
├── dpapi.ts # Windows DPAPI 主密钥保护
|
|
214
|
-
├── types.ts # 类型定义
|
|
215
|
-
└── cli.ts # CLI 入口
|
|
216
|
-
test/ # 自动化测试(node:test)
|
|
217
|
-
docs/ # 需求、可行性、概要设计、ADR
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
## 开发
|
|
221
|
-
|
|
222
|
-
```bash
|
|
223
|
-
npm test # 构建 + 运行全部测试
|
|
224
|
-
npm run build # 仅编译 TypeScript
|
|
225
|
-
npm start # 启动代理
|
|
226
|
-
npm run khat -- status # 源码运行任意 CLI 子命令
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
当前状态与设计决策见 [CONTEXT.md](./CONTEXT.md) 与 [docs/](./docs/)。
|
|
151
|
+
| `khat service is not installed` | service 注册不存在 | `khat service install` |
|
|
152
|
+
| `could not register the scheduled task` | Windows 计划任务注册失败 | 检查任务调度器权限,运行 `schtasks /Query /TN khat` 确认 |
|
|
153
|
+
| `could not load the launch agent` | macOS launchd 加载失败 | 检查 `~/Library/LaunchAgents/local.khat.plist` 是否被手动删除,运行 `launchctl list local.khat` |
|
|
154
|
+
| `could not enable the service` | Linux systemd 启用失败 | 检查 `~/.config/systemd/user/khat.service` 是否被删除,运行 `systemctl --user is-enabled khat` |
|
|
@@ -6,14 +6,15 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { createInterface } from 'node:readline/promises';
|
|
9
|
-
import { saveJsonAtomic, providerUrl, defaultConfig } from '
|
|
10
|
-
import { createKhatServer } from '
|
|
11
|
-
import { openStore, maskSecret } from '
|
|
12
|
-
import { Vault, generateAccessToken, ACCESS_TOKEN_REF } from '
|
|
13
|
-
import { createKeyProtector } from '
|
|
14
|
-
import { exportPortable, importPortable } from '
|
|
15
|
-
import { LogWriter } from '
|
|
16
|
-
import { runDoctor as inspectDoctor, formatDoctorSuggestion } from '
|
|
9
|
+
import { saveJsonAtomic, providerUrl, defaultConfig } from '../core/config.js';
|
|
10
|
+
import { createKhatServer } from '../network/server.js';
|
|
11
|
+
import { openStore, maskSecret } from '../data/store.js';
|
|
12
|
+
import { Vault, generateAccessToken, ACCESS_TOKEN_REF } from '../data/vault.js';
|
|
13
|
+
import { createKeyProtector } from '../data/key-protector.js';
|
|
14
|
+
import { exportPortable, importPortable } from '../data/portable-vault.js';
|
|
15
|
+
import { LogWriter } from '../core/logger.js';
|
|
16
|
+
import { runDoctor as inspectDoctor, formatDoctorSuggestion } from '../doctor.js';
|
|
17
|
+
import { createServiceManager } from '../service.js';
|
|
17
18
|
const dataDir = process.env.KHAT_HOME ?? join(homedir(), '.khat');
|
|
18
19
|
const configPath = join(dataDir, 'config.json');
|
|
19
20
|
const statePath = join(dataDir, 'state.json');
|
|
@@ -30,6 +31,9 @@ Setup
|
|
|
30
31
|
start [--foreground] [--port <n>] start the proxy as a background daemon (or foreground with --foreground);
|
|
31
32
|
--port <n> changes the persisted listen port
|
|
32
33
|
stop stop the background daemon
|
|
34
|
+
service install register khat to auto-start at logon (Task Scheduler / launchd / systemd --user)
|
|
35
|
+
service uninstall remove the auto-start registration
|
|
36
|
+
service status show the service registration and daemon state
|
|
33
37
|
status show providers, keys, routes and key health
|
|
34
38
|
doctor detect local Agent Tools and show connection guidance
|
|
35
39
|
log [--tail <n>] show recent masked proxy request logs
|
|
@@ -250,6 +254,28 @@ async function daemonRunning() {
|
|
|
250
254
|
return false;
|
|
251
255
|
}
|
|
252
256
|
}
|
|
257
|
+
async function readDaemonPid() {
|
|
258
|
+
if (!(await daemonRunning()))
|
|
259
|
+
return undefined;
|
|
260
|
+
try {
|
|
261
|
+
return Number.parseInt((await readFile(pidPath, 'utf8')).trim(), 10);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function printServiceStatus(service, daemonPid) {
|
|
268
|
+
if (!service.installed) {
|
|
269
|
+
console.log('khat service is not installed (run khat service install)');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const enabled = service.enabled === null ? 'unknown' : service.enabled ? 'on' : 'off';
|
|
273
|
+
const running = service.running === null ? 'unknown' : service.running ? 'yes' : 'no';
|
|
274
|
+
console.log('khat service');
|
|
275
|
+
console.log(` auto-start: ${enabled}`);
|
|
276
|
+
console.log(` running: ${running}${service.detail ? ` (${service.detail})` : ''}`);
|
|
277
|
+
console.log(` daemon: ${daemonPid !== undefined ? `running (pid ${daemonPid})` : 'not running'}`);
|
|
278
|
+
}
|
|
253
279
|
async function runForeground() {
|
|
254
280
|
const store = await openInitializedStore();
|
|
255
281
|
const secrets = {};
|
|
@@ -375,7 +401,7 @@ async function runTui() {
|
|
|
375
401
|
if (!token)
|
|
376
402
|
throw new Error('no access token found; run khat init');
|
|
377
403
|
await ensureDaemon();
|
|
378
|
-
const { runTui: startTui } = await import('
|
|
404
|
+
const { runTui: startTui } = await import('../ui/tui/tui-main.js');
|
|
379
405
|
await startTui(`http://${store.config.bind}:${store.config.port}`, token);
|
|
380
406
|
}
|
|
381
407
|
async function runUi() {
|
|
@@ -504,6 +530,25 @@ try {
|
|
|
504
530
|
case 'stop':
|
|
505
531
|
await runStop();
|
|
506
532
|
break;
|
|
533
|
+
case 'service': {
|
|
534
|
+
const action = positionals[0];
|
|
535
|
+
const manager = createServiceManager(process.platform, { dataDir });
|
|
536
|
+
if (action === 'install') {
|
|
537
|
+
await openInitializedStore();
|
|
538
|
+
console.log(await manager.install({ startNow: !(await daemonRunning()) }));
|
|
539
|
+
}
|
|
540
|
+
else if (action === 'uninstall') {
|
|
541
|
+
console.log(await manager.uninstall());
|
|
542
|
+
}
|
|
543
|
+
else if (action === 'status') {
|
|
544
|
+
printServiceStatus(await manager.status(), await readDaemonPid());
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
console.error('usage: khat service install | uninstall | status');
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
}
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
507
552
|
case 'status':
|
|
508
553
|
printStatus(await openInitializedStore());
|
|
509
554
|
break;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { providerUrl } from '
|
|
1
|
+
import { providerUrl } from '../core/config.js';
|
|
2
2
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
3
3
|
/** Minimal one-token ping shared by the Admin probe endpoint and the background prober. */
|
|
4
4
|
export async function probeKey(provider, model, secret) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
3
|
-
import { dpapiProtector } from '
|
|
3
|
+
import { dpapiProtector } from '../data/dpapi.js';
|
|
4
4
|
const SERVICE = 'khat-vault';
|
|
5
5
|
const ACCOUNT = 'khat';
|
|
6
6
|
function run(command, args, input = '') {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
2
2
|
import { access, mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { saveJsonAtomic, validateConfig, defaultConfig } from '
|
|
5
|
-
import { Vault } from '
|
|
4
|
+
import { saveJsonAtomic, validateConfig, defaultConfig } from '../core/config.js';
|
|
5
|
+
import { Vault } from '../data/vault.js';
|
|
6
6
|
const FORMAT = 'khat-vault-export';
|
|
7
7
|
const VERSION = 1;
|
|
8
8
|
const AAD = 'khat-vault-export-v1';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { readJsonFile, saveJsonAtomic, loadState, validateConfig, defaultConfig } from '
|
|
4
|
-
import { Vault } from '
|
|
3
|
+
import { readJsonFile, saveJsonAtomic, loadState, validateConfig, defaultConfig } from '../core/config.js';
|
|
4
|
+
import { Vault } from '../data/vault.js';
|
|
5
5
|
/** Ids appear in `provider/model` routing syntax and in vault refs, so they cannot contain `/`. */
|
|
6
6
|
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
7
7
|
export function maskSecret(value) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { saveJsonAtomic } from '
|
|
3
|
+
import { saveJsonAtomic } from '../core/config.js';
|
|
4
4
|
const VAULT_VERSION = 1;
|
|
5
5
|
const AAD = 'khat-vault-v1';
|
|
6
6
|
// Releases before the kay->khat rename wrote their payload under this AAD; files saved that way stay readable and are rewritten under the current AAD on their next save().
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, setKeyEnabled, addRoute, updateRoute, setRouteEnabled, removeRoute, maskSecret } from '
|
|
2
|
-
import { LogWriter } from '
|
|
1
|
+
import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, setKeyEnabled, addRoute, updateRoute, setRouteEnabled, removeRoute, maskSecret } from '../data/store.js';
|
|
2
|
+
import { LogWriter } from '../core/logger.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { UiSessions, serveUi } from '
|
|
5
|
-
import { ACCESS_TOKEN_REF, generateAccessToken } from '
|
|
6
|
-
import { providerUrl } from '
|
|
7
|
-
import { findRoute, routeUpstreamModel } from '
|
|
4
|
+
import { UiSessions, serveUi } from '../ui/web-ui.js';
|
|
5
|
+
import { ACCESS_TOKEN_REF, generateAccessToken } from '../data/vault.js';
|
|
6
|
+
import { providerUrl } from '../core/config.js';
|
|
7
|
+
import { findRoute, routeUpstreamModel } from '../core/router.js';
|
|
8
8
|
import { keyBlocked } from './selector.js';
|
|
9
|
-
import { probeKey } from '
|
|
10
|
-
import { readExport, applyImport } from '
|
|
9
|
+
import { probeKey } from '../core/probe.js';
|
|
10
|
+
import { readExport, applyImport } from '../data/portable-vault.js';
|
|
11
11
|
// Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
|
|
12
12
|
// only from loopback and behind the proxy access token, so a local non-loopback
|
|
13
13
|
// client or a process without the token cannot mutate config/state/vault.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { watch } from 'node:fs';
|
|
3
|
-
import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from '
|
|
4
|
-
import { LogWriter } from '
|
|
3
|
+
import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from '../core/config.js';
|
|
4
|
+
import { LogWriter } from '../core/logger.js';
|
|
5
5
|
import { join, basename } from 'node:path';
|
|
6
|
-
import { findRoute, routeUpstreamModel, resolveProvider, isRouteDisabled } from '
|
|
6
|
+
import { findRoute, routeUpstreamModel, resolveProvider, isRouteDisabled } from '../core/router.js';
|
|
7
7
|
import { WeightedSelector } from './selector.js';
|
|
8
|
-
import { handleAdmin } from '
|
|
9
|
-
import { enableKey } from '
|
|
10
|
-
import { probeKey } from '
|
|
11
|
-
import { ACCESS_TOKEN_REF } from '
|
|
8
|
+
import { handleAdmin } from '../network/admin.js';
|
|
9
|
+
import { enableKey } from '../data/store.js';
|
|
10
|
+
import { probeKey } from '../core/probe.js';
|
|
11
|
+
import { ACCESS_TOKEN_REF } from '../data/vault.js';
|
|
12
12
|
const RETRYABLE = new Set([401, 402, 429]);
|
|
13
13
|
const HOP_BY_HOP = new Set(['content-length', 'transfer-encoding', 'connection']);
|
|
14
14
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
@@ -27,6 +27,13 @@ const ENDPOINTS = {
|
|
|
27
27
|
'/v1/responses': 'openai',
|
|
28
28
|
'/v1/messages': 'anthropic'
|
|
29
29
|
};
|
|
30
|
+
function modelList(config) {
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const data = config.routes
|
|
33
|
+
.filter((route) => route.enabled !== false && !seen.has(route.model) && seen.add(route.model))
|
|
34
|
+
.map((route) => ({ id: route.model, object: 'model', created: 0, owned_by: route.provider }));
|
|
35
|
+
return { object: 'list', data };
|
|
36
|
+
}
|
|
30
37
|
/** Rewrite the client-facing auth into the upstream auth convention for the target protocol. */
|
|
31
38
|
function upstreamHeaders(protocol, secret, accept) {
|
|
32
39
|
if (protocol === 'anthropic')
|
|
@@ -132,11 +139,14 @@ export function createKhatServer(options) {
|
|
|
132
139
|
return;
|
|
133
140
|
}
|
|
134
141
|
const currentAccessToken = options.store?.vault.get(ACCESS_TOKEN_REF) ?? options.accessToken;
|
|
135
|
-
if (currentAccessToken
|
|
142
|
+
if (!currentAccessToken || req.headers.authorization !== `Bearer ${currentAccessToken}`)
|
|
136
143
|
return json(res, 401, { error: { message: 'Unauthorized' } });
|
|
137
|
-
|
|
144
|
+
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
145
|
+
if (req.method === 'GET' && (pathname === '/models' || pathname === '/v1/models'))
|
|
146
|
+
return json(res, 200, modelList(options.config));
|
|
147
|
+
if (req.method !== 'POST' || !(pathname in ENDPOINTS))
|
|
138
148
|
return json(res, 404, { error: { message: 'Not found' } });
|
|
139
|
-
const protocol = ENDPOINTS[
|
|
149
|
+
const protocol = ENDPOINTS[pathname];
|
|
140
150
|
const body = await readBody(req, options.config.requestBodyLimitMB * 1024 * 1024);
|
|
141
151
|
let parsed;
|
|
142
152
|
try {
|
|
@@ -180,7 +190,7 @@ export function createKhatServer(options) {
|
|
|
180
190
|
const headerTimer = setTimeout(() => upstreamAbort(abort, 'upstream response header timeout'), timeouts().headerMs);
|
|
181
191
|
let upstream;
|
|
182
192
|
try {
|
|
183
|
-
upstream = await fetch(providerUrl(provider.baseUrl,
|
|
193
|
+
upstream = await fetch(providerUrl(provider.baseUrl, pathname), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: upstreamBody, signal: abort.signal });
|
|
184
194
|
}
|
|
185
195
|
catch (error) {
|
|
186
196
|
if (error?.upstreamTimeout)
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, mkdir, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const WINDOWS_TASK_NAME = 'khat';
|
|
9
|
+
const LAUNCHD_LABEL = 'local.khat';
|
|
10
|
+
const SYSTEMD_UNIT = 'khat';
|
|
11
|
+
async function defaultRunner(command, args) {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout, stderr } = await execFileAsync(command, args, { windowsHide: true, timeout: 30_000 });
|
|
14
|
+
return { code: 0, stdout: String(stdout), stderr: String(stderr) };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error?.code === 'ENOENT')
|
|
18
|
+
return { code: null, stdout: '', stderr: `${command} is not available` };
|
|
19
|
+
return { code: typeof error?.code === 'number' ? error.code : null, stdout: String(error?.stdout ?? ''), stderr: String(error?.stderr ?? error?.message ?? 'command failed') };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function fileExists(path) {
|
|
23
|
+
try {
|
|
24
|
+
await access(path);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function xmlEscape(value) {
|
|
32
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
33
|
+
}
|
|
34
|
+
function resolveOptions(options) {
|
|
35
|
+
const home = options.homeDir ?? homedir();
|
|
36
|
+
return {
|
|
37
|
+
runner: options.run ?? defaultRunner,
|
|
38
|
+
homeDir: home,
|
|
39
|
+
dataDir: options.dataDir ?? process.env.KHAT_HOME ?? join(home, '.khat'),
|
|
40
|
+
distDir: options.distDir ?? dirname(fileURLToPath(new URL('./supervisor.js', import.meta.url))),
|
|
41
|
+
execPath: options.execPath ?? process.execPath,
|
|
42
|
+
userId: options.userId ?? (process.env.USERDOMAIN && process.env.USERNAME ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : undefined),
|
|
43
|
+
uid: options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : undefined)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function commandFailure(result, action) {
|
|
47
|
+
return `${action}: ${(result.stderr || result.stdout).trim() || `command exited with code ${result.code}`}`;
|
|
48
|
+
}
|
|
49
|
+
export function buildTaskXml(options) {
|
|
50
|
+
const triggerUser = options.userId ? `\n <UserId>${xmlEscape(options.userId)}</UserId>` : '';
|
|
51
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
52
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
53
|
+
<RegistrationInfo>
|
|
54
|
+
<Description>Khat proxy supervisor; starts the khat daemon when you log on</Description>
|
|
55
|
+
</RegistrationInfo>
|
|
56
|
+
<Triggers>
|
|
57
|
+
<LogonTrigger>
|
|
58
|
+
<Enabled>true</Enabled>${triggerUser}
|
|
59
|
+
</LogonTrigger>
|
|
60
|
+
</Triggers>
|
|
61
|
+
<Principals>
|
|
62
|
+
<Principal id="Author">
|
|
63
|
+
<LogonType>InteractiveToken</LogonType>
|
|
64
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
65
|
+
</Principal>
|
|
66
|
+
</Principals>
|
|
67
|
+
<Settings>
|
|
68
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
69
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
70
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
71
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
72
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
73
|
+
<WakeToRun>false</WakeToRun>
|
|
74
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
75
|
+
<Priority>7</Priority>
|
|
76
|
+
</Settings>
|
|
77
|
+
<Actions Context="Author">
|
|
78
|
+
<Exec>
|
|
79
|
+
<Command>${xmlEscape(options.command)}</Command>
|
|
80
|
+
<Arguments>${xmlEscape(options.arguments)}</Arguments>
|
|
81
|
+
</Exec>
|
|
82
|
+
</Actions>
|
|
83
|
+
</Task>`;
|
|
84
|
+
}
|
|
85
|
+
export function buildLaunchdPlist(options) {
|
|
86
|
+
const programArgs = options.programArgs.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n');
|
|
87
|
+
const env = Object.entries(options.env).map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`).join('\n');
|
|
88
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
89
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
90
|
+
<plist version="1.0">
|
|
91
|
+
<dict>
|
|
92
|
+
<key>Label</key>
|
|
93
|
+
<string>${xmlEscape(options.label)}</string>
|
|
94
|
+
<key>ProgramArguments</key>
|
|
95
|
+
<array>
|
|
96
|
+
<string>${xmlEscape(options.program)}</string>
|
|
97
|
+
${programArgs}
|
|
98
|
+
</array>
|
|
99
|
+
<key>WorkingDirectory</key>
|
|
100
|
+
<string>${xmlEscape(options.workDir)}</string>
|
|
101
|
+
<key>EnvironmentVariables</key>
|
|
102
|
+
<dict>
|
|
103
|
+
${env}
|
|
104
|
+
</dict>
|
|
105
|
+
<key>RunAtLoad</key>
|
|
106
|
+
<true/>
|
|
107
|
+
<key>StandardOutPath</key>
|
|
108
|
+
<string>${xmlEscape(options.logPath)}</string>
|
|
109
|
+
<key>StandardErrorPath</key>
|
|
110
|
+
<string>${xmlEscape(options.logPath)}</string>
|
|
111
|
+
</dict>
|
|
112
|
+
</plist>`;
|
|
113
|
+
}
|
|
114
|
+
export function buildSystemdUnit(options) {
|
|
115
|
+
const execStart = options.execStart.map((part) => `"${part}"`).join(' ');
|
|
116
|
+
const env = Object.entries(options.env).map(([key, value]) => `Environment="${key}=${value}"`).join('\n');
|
|
117
|
+
return `[Unit]
|
|
118
|
+
Description=Khat proxy supervisor
|
|
119
|
+
|
|
120
|
+
[Service]
|
|
121
|
+
ExecStart=${execStart}
|
|
122
|
+
WorkingDirectory="${options.workDir}"
|
|
123
|
+
${env}
|
|
124
|
+
Restart=no
|
|
125
|
+
|
|
126
|
+
[Install]
|
|
127
|
+
WantedBy=default.target
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
// Windows: a scheduled task with an interactive token, not an NSSM service —
|
|
131
|
+
// the vault master key is protected with user-scope DPAPI (ADR 0004), which
|
|
132
|
+
// LocalSystem cannot decrypt, and NSSM cannot run as the current user without
|
|
133
|
+
// that user's password.
|
|
134
|
+
function windowsManager(options) {
|
|
135
|
+
const { runner, dataDir, distDir, execPath, userId } = resolveOptions(options);
|
|
136
|
+
const supervisorPath = join(distDir, 'supervisor.js');
|
|
137
|
+
async function install({ startNow = true } = {}) {
|
|
138
|
+
await mkdir(dataDir, { recursive: true });
|
|
139
|
+
const xmlPath = join(dataDir, 'service-task.xml');
|
|
140
|
+
// The task XML cannot set environment variables, so a cmd wrapper pins
|
|
141
|
+
// KHAT_HOME; the quoted `set` form avoids trailing-space capture.
|
|
142
|
+
const arguments_ = `/c set "KHAT_HOME=${dataDir}" && "${execPath}" "${supervisorPath}"`;
|
|
143
|
+
const xml = buildTaskXml({ userId, command: 'cmd.exe', arguments: arguments_ });
|
|
144
|
+
await writeFile(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xml, 'utf16le')]));
|
|
145
|
+
const created = await runner('schtasks', ['/Create', '/F', '/TN', WINDOWS_TASK_NAME, '/XML', xmlPath]);
|
|
146
|
+
if (created.code !== 0)
|
|
147
|
+
throw new Error(`${commandFailure(created, 'could not register the scheduled task')} (definition kept at ${xmlPath})`);
|
|
148
|
+
await unlink(xmlPath).catch(() => undefined);
|
|
149
|
+
if (!startNow)
|
|
150
|
+
return `scheduled task '${WINDOWS_TASK_NAME}' registered; it auto-starts the daemon at your next logon`;
|
|
151
|
+
const started = await runner('schtasks', ['/Run', '/TN', WINDOWS_TASK_NAME]);
|
|
152
|
+
if (started.code !== 0)
|
|
153
|
+
throw new Error(commandFailure(started, 'task registered but could not be started'));
|
|
154
|
+
return `scheduled task '${WINDOWS_TASK_NAME}' registered and running; it auto-starts at logon`;
|
|
155
|
+
}
|
|
156
|
+
async function uninstall() {
|
|
157
|
+
const query = await runner('schtasks', ['/Query', '/TN', WINDOWS_TASK_NAME]);
|
|
158
|
+
if (query.code !== 0)
|
|
159
|
+
return 'khat service is not installed';
|
|
160
|
+
const removed = await runner('schtasks', ['/Delete', '/F', '/TN', WINDOWS_TASK_NAME]);
|
|
161
|
+
if (removed.code !== 0)
|
|
162
|
+
throw new Error(commandFailure(removed, 'could not remove the scheduled task'));
|
|
163
|
+
return `scheduled task '${WINDOWS_TASK_NAME}' removed`;
|
|
164
|
+
}
|
|
165
|
+
async function status() {
|
|
166
|
+
const query = await runner('schtasks', ['/Query', '/TN', WINDOWS_TASK_NAME]);
|
|
167
|
+
if (query.code !== 0)
|
|
168
|
+
return { installed: false, enabled: null, running: null, detail: '' };
|
|
169
|
+
// schtasks /Query status text is localized; PowerShell's StateEnum is not.
|
|
170
|
+
const state = await runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-ScheduledTask -TaskName '${WINDOWS_TASK_NAME}' -ErrorAction SilentlyContinue).State`]);
|
|
171
|
+
const value = Number.parseInt(state.stdout.trim(), 10);
|
|
172
|
+
if (!Number.isInteger(value))
|
|
173
|
+
return { installed: true, enabled: null, running: null, detail: 'task state could not be read' };
|
|
174
|
+
return {
|
|
175
|
+
installed: true,
|
|
176
|
+
enabled: value !== 1,
|
|
177
|
+
running: value === 4,
|
|
178
|
+
detail: value === 4 ? 'task running' : value === 3 ? 'task ready (not running)' : value === 1 ? 'task disabled' : `task state ${value}`
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { install, uninstall, status };
|
|
182
|
+
}
|
|
183
|
+
// macOS: launchd user agent. RunAtLoad starts it at login; KeepAlive stays off
|
|
184
|
+
// so the supervisor's own crash circuit breaker is the only restart authority.
|
|
185
|
+
function darwinManager(options) {
|
|
186
|
+
const { runner, homeDir, dataDir, distDir, execPath, uid } = resolveOptions(options);
|
|
187
|
+
const supervisorPath = join(distDir, 'supervisor.js');
|
|
188
|
+
const plistPath = join(homeDir, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
189
|
+
const target = `gui/${uid}/${LAUNCHD_LABEL}`;
|
|
190
|
+
async function install({ startNow = true } = {}) {
|
|
191
|
+
if (uid === undefined)
|
|
192
|
+
throw new Error('could not determine the user id for launchctl');
|
|
193
|
+
await mkdir(dirname(plistPath), { recursive: true });
|
|
194
|
+
await mkdir(dataDir, { recursive: true });
|
|
195
|
+
const plist = buildLaunchdPlist({
|
|
196
|
+
label: LAUNCHD_LABEL,
|
|
197
|
+
program: execPath,
|
|
198
|
+
programArgs: [supervisorPath],
|
|
199
|
+
workDir: distDir,
|
|
200
|
+
env: { KHAT_HOME: dataDir },
|
|
201
|
+
logPath: join(dataDir, 'service.log')
|
|
202
|
+
});
|
|
203
|
+
await writeFile(plistPath, plist, 'utf8');
|
|
204
|
+
if (!startNow)
|
|
205
|
+
return `launch agent ${LAUNCHD_LABEL} installed; it auto-starts the daemon at your next logon`;
|
|
206
|
+
await runner('launchctl', ['bootout', target]);
|
|
207
|
+
const boot = await runner('launchctl', ['bootstrap', `gui/${uid}`, plistPath]);
|
|
208
|
+
if (boot.code !== 0)
|
|
209
|
+
throw new Error(commandFailure(boot, 'could not load the launch agent'));
|
|
210
|
+
return `launch agent ${LAUNCHD_LABEL} installed and running; it auto-starts at logon`;
|
|
211
|
+
}
|
|
212
|
+
async function uninstall() {
|
|
213
|
+
if (!(await fileExists(plistPath)))
|
|
214
|
+
return 'khat service is not installed';
|
|
215
|
+
await runner('launchctl', ['bootout', target]);
|
|
216
|
+
await unlink(plistPath).catch(() => undefined);
|
|
217
|
+
return `launch agent ${LAUNCHD_LABEL} removed`;
|
|
218
|
+
}
|
|
219
|
+
async function status() {
|
|
220
|
+
if (!(await fileExists(plistPath)))
|
|
221
|
+
return { installed: false, enabled: null, running: null, detail: '' };
|
|
222
|
+
const printed = await runner('launchctl', ['print', target]);
|
|
223
|
+
if (printed.code !== 0)
|
|
224
|
+
return { installed: true, enabled: true, running: false, detail: 'not loaded; starts at next logon' };
|
|
225
|
+
const state = printed.stdout.match(/state\s*=\s*(\S+)/)?.[1] ?? 'unknown';
|
|
226
|
+
return { installed: true, enabled: true, running: state === 'running', detail: state === 'running' ? 'agent running' : `agent state: ${state}` };
|
|
227
|
+
}
|
|
228
|
+
return { install, uninstall, status };
|
|
229
|
+
}
|
|
230
|
+
// Linux: systemd user unit. Restart=no — the supervisor owns crash backoff;
|
|
231
|
+
// the unit only has to launch it once per login/boot.
|
|
232
|
+
function linuxManager(options) {
|
|
233
|
+
const { runner, homeDir, dataDir, distDir, execPath } = resolveOptions(options);
|
|
234
|
+
const supervisorPath = join(distDir, 'supervisor.js');
|
|
235
|
+
const unitPath = join(homeDir, '.config', 'systemd', 'user', `${SYSTEMD_UNIT}.service`);
|
|
236
|
+
async function install({ startNow = true } = {}) {
|
|
237
|
+
await mkdir(dirname(unitPath), { recursive: true });
|
|
238
|
+
const unit = buildSystemdUnit({ execStart: [execPath, supervisorPath], workDir: distDir, env: { KHAT_HOME: dataDir } });
|
|
239
|
+
await writeFile(unitPath, unit, 'utf8');
|
|
240
|
+
const reloaded = await runner('systemctl', ['--user', 'daemon-reload']);
|
|
241
|
+
if (reloaded.code !== 0)
|
|
242
|
+
throw new Error(commandFailure(reloaded, 'could not reload the systemd user units'));
|
|
243
|
+
const enable = await runner('systemctl', ['--user', ...(startNow ? ['enable', '--now'] : ['enable']), SYSTEMD_UNIT]);
|
|
244
|
+
if (enable.code !== 0)
|
|
245
|
+
throw new Error(commandFailure(enable, 'could not enable the service'));
|
|
246
|
+
// Without lingering, user units only run while the user is logged in.
|
|
247
|
+
const linger = await runner('loginctl', ['enable-linger']);
|
|
248
|
+
const lingerNote = linger.code === 0 ? '' : ' (warning: could not enable lingering, so the service starts at login rather than at boot)';
|
|
249
|
+
return `systemd user service ${SYSTEMD_UNIT} installed${startNow ? ' and running' : ''}; it auto-starts at login${lingerNote}`;
|
|
250
|
+
}
|
|
251
|
+
async function uninstall() {
|
|
252
|
+
if (!(await fileExists(unitPath)))
|
|
253
|
+
return 'khat service is not installed';
|
|
254
|
+
await runner('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]);
|
|
255
|
+
await unlink(unitPath).catch(() => undefined);
|
|
256
|
+
await runner('systemctl', ['--user', 'daemon-reload']);
|
|
257
|
+
return `systemd user service ${SYSTEMD_UNIT} removed`;
|
|
258
|
+
}
|
|
259
|
+
async function status() {
|
|
260
|
+
if (!(await fileExists(unitPath)))
|
|
261
|
+
return { installed: false, enabled: null, running: null, detail: '' };
|
|
262
|
+
const enabledResult = await runner('systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT]);
|
|
263
|
+
const activeResult = await runner('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]);
|
|
264
|
+
const enabledText = enabledResult.stdout.trim();
|
|
265
|
+
const activeText = activeResult.stdout.trim();
|
|
266
|
+
return {
|
|
267
|
+
installed: true,
|
|
268
|
+
enabled: enabledText === 'enabled' ? true : enabledText === 'disabled' ? false : null,
|
|
269
|
+
running: activeText === 'active',
|
|
270
|
+
detail: activeText === 'active' ? 'unit active' : `unit ${activeText || 'unknown'}`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { install, uninstall, status };
|
|
274
|
+
}
|
|
275
|
+
export function createServiceManager(platform, options = {}) {
|
|
276
|
+
if (platform === 'win32')
|
|
277
|
+
return windowsManager(options);
|
|
278
|
+
if (platform === 'darwin')
|
|
279
|
+
return darwinManager(options);
|
|
280
|
+
if (platform === 'linux')
|
|
281
|
+
return linuxManager(options);
|
|
282
|
+
throw new Error(`unsupported platform '${platform}': khat service requires Windows Task Scheduler, macOS launchd, or Linux systemd`);
|
|
283
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alilis/k-hat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
|
-
"khat": "dist/cli.js"
|
|
11
|
+
"khat": "dist/cli/cli.js"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"dist"
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -p tsconfig.json",
|
|
38
38
|
"prepare": "npm run build",
|
|
39
|
-
"start": "node dist/cli.js start",
|
|
40
|
-
"khat": "node dist/cli.js",
|
|
39
|
+
"start": "node dist/cli/cli.js start",
|
|
40
|
+
"khat": "node dist/cli/cli.js",
|
|
41
41
|
"test": "npm run build && node --test test/**/*.test.js",
|
|
42
42
|
"bench": "node bench/bench.js"
|
|
43
43
|
},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|