git-ai-control 0.2.0 → 0.4.0

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.
@@ -0,0 +1,451 @@
1
+ import {spawnSync} from "node:child_process"
2
+ import fs from "node:fs"
3
+ import net from "node:net"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import {fileURLToPath} from "node:url"
7
+
8
+ import {
9
+ SERVICE_DEFINITIONS,
10
+ gitAiBinaryCandidates,
11
+ renderLaunchAgent,
12
+ renderSystemdUnit,
13
+ renderWindowsWrapper,
14
+ windowsTaskCommand,
15
+ } from "./platform-services.mjs"
16
+
17
+ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
18
+ const LOCAL_ENDPOINTS = {
19
+ enable: true,
20
+ commit_endpoint: "http://127.0.0.1:38741/legacy/commit",
21
+ checkpoint_endpoint: "http://127.0.0.1:38741/legacy/checkpoint",
22
+ token_usage_endpoint: "http://127.0.0.1:38741/legacy/token-usage",
23
+ prompt_duration_endpoint: "http://127.0.0.1:38741/prompt-duration",
24
+ commit_endpoint_v2: "http://127.0.0.1:38741/commit",
25
+ checkpoint_endpoint_v2: "http://127.0.0.1:38741/checkpoint",
26
+ token_usage_endpoint_v2: "http://127.0.0.1:38741/token-usage",
27
+ skill_usage_endpoint_v2: "http://127.0.0.1:38741/skill-usage",
28
+ agent_usage_endpoint_v2: "http://127.0.0.1:38741/agent-usage",
29
+ prompt_report_endpoint_v2: "http://127.0.0.1:38741/prompt-report",
30
+ }
31
+ const ROUTE_KEYS = {
32
+ "/legacy/commit": "commit_endpoint",
33
+ "/legacy/checkpoint": "checkpoint_endpoint",
34
+ "/legacy/token-usage": "token_usage_endpoint",
35
+ "/prompt-duration": "prompt_duration_endpoint",
36
+ "/commit": "commit_endpoint_v2",
37
+ "/checkpoint": "checkpoint_endpoint_v2",
38
+ "/token-usage": "token_usage_endpoint_v2",
39
+ "/skill-usage": "skill_usage_endpoint_v2",
40
+ "/agent-usage": "agent_usage_endpoint_v2",
41
+ "/prompt-report": "prompt_report_endpoint_v2",
42
+ }
43
+
44
+ function run(command, args, options = {}) {
45
+ const result = spawnSync(command, args, {
46
+ encoding: "utf8",
47
+ stdio: options.capture ? "pipe" : "inherit",
48
+ ...options,
49
+ })
50
+ if (result.error && !options.allowFailure) {
51
+ throw new Error(`${command} 执行失败:${result.error.message}`)
52
+ }
53
+ if (result.status !== 0 && !options.allowFailure) {
54
+ const details = String(result.stderr || result.stdout || "").trim()
55
+ throw new Error(`${command} 执行失败${details ? `:${details}` : ""}`)
56
+ }
57
+ return result
58
+ }
59
+
60
+ function commandExists(command, args = ["--version"]) {
61
+ const result = run(command, args, {allowFailure: true, capture: true})
62
+ return !result.error && result.status === 0
63
+ }
64
+
65
+ function resolveExecutable(command, platform) {
66
+ if (path.isAbsolute(command)) {
67
+ return command
68
+ }
69
+ if (platform === "win32") {
70
+ const result = run("where.exe", [command], {allowFailure: true, capture: true})
71
+ const match = String(result.stdout || "")
72
+ .split(/\r?\n/)
73
+ .find(Boolean)
74
+ return match || command
75
+ }
76
+ for (const directory of String(process.env.PATH || "").split(path.delimiter)) {
77
+ if (!directory) {
78
+ continue
79
+ }
80
+ const candidate = path.join(directory, command)
81
+ try {
82
+ fs.accessSync(candidate, fs.constants.X_OK)
83
+ return candidate
84
+ } catch {
85
+ // 继续检查 PATH 中的下一个目录。
86
+ }
87
+ }
88
+ return command
89
+ }
90
+
91
+ function findPython(platform) {
92
+ const candidates = platform === "win32"
93
+ ? [["py", ["-3"]], ["python", []], ["python3", []]]
94
+ : [["python3", []], ["python", []]]
95
+ for (const [command, prefix] of candidates) {
96
+ if (commandExists(command, [...prefix, "--version"])) {
97
+ return [resolveExecutable(command, platform), ...prefix]
98
+ }
99
+ }
100
+ throw new Error("未找到 Python 3,请先安装 Python 3 并加入 PATH")
101
+ }
102
+
103
+ function isExternalHttpUrl(value) {
104
+ try {
105
+ const parsed = new URL(value)
106
+ return (
107
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
108
+ !["127.0.0.1", "localhost", "::1"].includes(parsed.hostname)
109
+ )
110
+ } catch {
111
+ return false
112
+ }
113
+ }
114
+
115
+ function readJson(filePath, fallback) {
116
+ try {
117
+ return JSON.parse(fs.readFileSync(filePath, "utf8"))
118
+ } catch (error) {
119
+ if (error.code === "ENOENT") {
120
+ return fallback
121
+ }
122
+ throw error
123
+ }
124
+ }
125
+
126
+ function writeJsonAtomic(filePath, value) {
127
+ fs.mkdirSync(path.dirname(filePath), {recursive: true})
128
+ const temporaryPath = path.join(
129
+ path.dirname(filePath),
130
+ `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
131
+ )
132
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {mode: 0o600})
133
+ fs.renameSync(temporaryPath, filePath)
134
+ if (process.platform !== "win32") {
135
+ fs.chmodSync(filePath, 0o600)
136
+ }
137
+ }
138
+
139
+ function requireFile(filePath) {
140
+ if (!fs.existsSync(filePath)) {
141
+ throw new Error(`缺少安装文件:${filePath}`)
142
+ }
143
+ }
144
+
145
+ function extractLegacyRoutes(runtimePath) {
146
+ if (!fs.existsSync(runtimePath)) {
147
+ return {}
148
+ }
149
+ const source = fs.readFileSync(runtimePath, "utf8")
150
+ const assignment = source.match(/^\s*UPSTREAMS\s*=\s*\{([\s\S]*?)^\}/m)
151
+ if (!assignment) {
152
+ return {}
153
+ }
154
+ const routes = {}
155
+ const entryPattern = /["']([^"']+)["']\s*:\s*["'](https?:\/\/[^"']+)["']/g
156
+ for (const match of assignment[1].matchAll(entryPattern)) {
157
+ if (isExternalHttpUrl(match[2])) {
158
+ routes[match[1]] = match[2]
159
+ }
160
+ }
161
+ return routes
162
+ }
163
+
164
+ function configureCustomMetrics(gitAiRoot) {
165
+ const metricsPath = path.join(gitAiRoot, "custom_metrics.json")
166
+ const upstreamsPath = path.join(gitAiRoot, "upstream_metrics.json")
167
+ const installedRuntime = path.join(gitAiRoot, "filters", "plugin_filter_runtime.py")
168
+ const config = readJson(metricsPath, {})
169
+ let routes = {}
170
+
171
+ if (fs.existsSync(upstreamsPath)) {
172
+ const saved = readJson(upstreamsPath, {})
173
+ const candidate = saved.routes ?? saved
174
+ if (
175
+ typeof candidate !== "object" ||
176
+ !Object.values(candidate).some(isExternalHttpUrl)
177
+ ) {
178
+ throw new Error("原上报端点备份无效,已停止安装")
179
+ }
180
+ routes = candidate
181
+ if (process.platform !== "win32") {
182
+ fs.chmodSync(upstreamsPath, 0o600)
183
+ }
184
+ } else {
185
+ for (const [route, key] of Object.entries(ROUTE_KEYS)) {
186
+ if (isExternalHttpUrl(config[key])) {
187
+ routes[route] = config[key]
188
+ }
189
+ }
190
+ const tokenUrl = routes["/token-usage"]
191
+ if (tokenUrl) {
192
+ routes["/token-usage/batch"] = `${tokenUrl.replace(/\/+$/, "")}/batch`
193
+ routes["/token-usage/stats"] = `${tokenUrl.replace(/\/+$/, "")}/stats`
194
+ }
195
+ if (Object.keys(routes).length === 0) {
196
+ routes = extractLegacyRoutes(installedRuntime)
197
+ }
198
+ if (Object.keys(routes).length === 0) {
199
+ throw new Error(
200
+ "无法从 custom_metrics.json 识别原上报端点;为避免丢失或回环请求,已停止安装",
201
+ )
202
+ }
203
+ writeJsonAtomic(upstreamsPath, {version: 1, routes})
204
+ }
205
+
206
+ writeJsonAtomic(metricsPath, {...config, ...LOCAL_ENDPOINTS})
207
+ }
208
+
209
+ function copyRuntime(gitAiRoot) {
210
+ const controlPanelDir = path.join(gitAiRoot, "control-panel")
211
+ const filterDir = path.join(gitAiRoot, "filters")
212
+ fs.mkdirSync(controlPanelDir, {recursive: true})
213
+ fs.mkdirSync(filterDir, {recursive: true})
214
+ fs.copyFileSync(
215
+ path.join(PROJECT_ROOT, "server.py"),
216
+ path.join(controlPanelDir, "server.py"),
217
+ )
218
+ fs.copyFileSync(
219
+ path.join(PROJECT_ROOT, "plugins", "upload-filter", "plugin_filter_runtime.py"),
220
+ path.join(filterDir, "plugin_filter_runtime.py"),
221
+ )
222
+ fs.rmSync(path.join(controlPanelDir, "static"), {recursive: true, force: true})
223
+ fs.cpSync(path.join(PROJECT_ROOT, "static"), path.join(controlPanelDir, "static"), {
224
+ recursive: true,
225
+ })
226
+ if (process.platform !== "win32") {
227
+ fs.chmodSync(path.join(controlPanelDir, "server.py"), 0o700)
228
+ fs.chmodSync(path.join(filterDir, "plugin_filter_runtime.py"), 0o700)
229
+ }
230
+ }
231
+
232
+ function writeServiceFiles(platform, gitAiRoot, pythonCommand) {
233
+ if (platform === "darwin") {
234
+ const directory = path.join(os.homedir(), "Library", "LaunchAgents")
235
+ fs.mkdirSync(directory, {recursive: true})
236
+ for (const service of SERVICE_DEFINITIONS) {
237
+ const target = path.join(directory, `${service.macLabel}.plist`)
238
+ fs.writeFileSync(target, renderLaunchAgent(service, gitAiRoot, pythonCommand), {
239
+ mode: 0o600,
240
+ })
241
+ }
242
+ return
243
+ }
244
+ if (platform === "linux") {
245
+ const directory = path.join(os.homedir(), ".config", "systemd", "user")
246
+ fs.mkdirSync(directory, {recursive: true})
247
+ for (const service of SERVICE_DEFINITIONS) {
248
+ fs.writeFileSync(
249
+ path.join(directory, service.linuxUnit),
250
+ renderSystemdUnit(service, gitAiRoot, pythonCommand),
251
+ )
252
+ }
253
+ return
254
+ }
255
+ if (platform === "win32") {
256
+ const directory = path.join(gitAiRoot, "services")
257
+ fs.mkdirSync(directory, {recursive: true})
258
+ for (const service of SERVICE_DEFINITIONS) {
259
+ fs.writeFileSync(
260
+ path.join(directory, `${service.key}.ps1`),
261
+ renderWindowsWrapper(service, gitAiRoot, pythonCommand),
262
+ )
263
+ }
264
+ return
265
+ }
266
+ throw new Error(`不支持当前操作系统:${platform}`)
267
+ }
268
+
269
+ function stopServices(platform) {
270
+ if (platform === "darwin") {
271
+ const domain = `gui/${process.getuid()}`
272
+ for (const service of SERVICE_DEFINITIONS) {
273
+ run("launchctl", ["bootout", `${domain}/${service.macLabel}`], {
274
+ allowFailure: true,
275
+ capture: true,
276
+ })
277
+ }
278
+ } else if (platform === "linux") {
279
+ for (const service of SERVICE_DEFINITIONS) {
280
+ run("systemctl", ["--user", "stop", service.linuxUnit], {
281
+ allowFailure: true,
282
+ capture: true,
283
+ })
284
+ }
285
+ } else if (platform === "win32") {
286
+ for (const service of SERVICE_DEFINITIONS) {
287
+ run("schtasks.exe", ["/End", "/TN", service.windowsTask], {
288
+ allowFailure: true,
289
+ capture: true,
290
+ })
291
+ }
292
+ }
293
+ }
294
+
295
+ function startServices(platform, gitAiRoot) {
296
+ if (platform === "darwin") {
297
+ const domain = `gui/${process.getuid()}`
298
+ for (const service of SERVICE_DEFINITIONS) {
299
+ const plist = path.join(
300
+ os.homedir(),
301
+ "Library",
302
+ "LaunchAgents",
303
+ `${service.macLabel}.plist`,
304
+ )
305
+ run("launchctl", ["bootstrap", domain, plist])
306
+ }
307
+ return
308
+ }
309
+ if (platform === "linux") {
310
+ run("systemctl", ["--user", "daemon-reload"])
311
+ for (const service of SERVICE_DEFINITIONS) {
312
+ run("systemctl", ["--user", "enable", "--now", service.linuxUnit])
313
+ }
314
+ return
315
+ }
316
+ if (platform === "win32") {
317
+ for (const service of SERVICE_DEFINITIONS) {
318
+ const wrapper = path.join(gitAiRoot, "services", `${service.key}.ps1`)
319
+ run("schtasks.exe", [
320
+ "/Create",
321
+ "/F",
322
+ "/SC",
323
+ "ONLOGON",
324
+ "/RL",
325
+ "LIMITED",
326
+ "/TN",
327
+ service.windowsTask,
328
+ "/TR",
329
+ windowsTaskCommand(wrapper),
330
+ ])
331
+ run("schtasks.exe", ["/Run", "/TN", service.windowsTask])
332
+ }
333
+ return
334
+ }
335
+ throw new Error(`不支持当前操作系统:${platform}`)
336
+ }
337
+
338
+ function portIsFree(port) {
339
+ return new Promise((resolve) => {
340
+ const server = net.createServer()
341
+ server.unref()
342
+ server.once("error", () => resolve(false))
343
+ server.listen({host: "127.0.0.1", port}, () => {
344
+ server.close(() => resolve(true))
345
+ })
346
+ })
347
+ }
348
+
349
+ async function waitForPortRelease(port) {
350
+ for (let attempt = 0; attempt < 20; attempt += 1) {
351
+ if (await portIsFree(port)) {
352
+ return
353
+ }
354
+ await new Promise((resolve) => setTimeout(resolve, 250))
355
+ }
356
+ throw new Error(`端口 ${port} 仍被占用,停止安装`)
357
+ }
358
+
359
+ async function waitForHttp(url, label) {
360
+ for (let attempt = 0; attempt < 30; attempt += 1) {
361
+ try {
362
+ const response = await fetch(url)
363
+ if (response.ok) {
364
+ return
365
+ }
366
+ } catch {
367
+ // 服务仍在启动。
368
+ }
369
+ await new Promise((resolve) => setTimeout(resolve, 500))
370
+ }
371
+ throw new Error(`${label}启动失败:${url}`)
372
+ }
373
+
374
+ export async function install(options = {}) {
375
+ const platform = options.platform ?? process.platform
376
+ if (!["darwin", "linux", "win32"].includes(platform)) {
377
+ throw new Error(`git-ai-control 不支持当前操作系统:${platform}`)
378
+ }
379
+ const gitAiRoot = path.resolve(
380
+ options.gitAiRoot ?? process.env.GIT_AI_ROOT ?? path.join(os.homedir(), ".git-ai"),
381
+ )
382
+ const pythonCommand = options.pythonCommand ?? findPython(platform)
383
+ const binary = gitAiBinaryCandidates(gitAiRoot, platform).find(fs.existsSync)
384
+ if (!binary) {
385
+ throw new Error(
386
+ `未找到 Git AI,请确认已安装到 ${gitAiBinaryCandidates(gitAiRoot, platform).join(" 或 ")}`,
387
+ )
388
+ }
389
+
390
+ for (const required of [
391
+ "server.py",
392
+ path.join("static", "index.html"),
393
+ path.join("plugins", "upload-filter", "plugin_filter_runtime.py"),
394
+ "policy.example.json",
395
+ ]) {
396
+ requireFile(path.join(PROJECT_ROOT, required))
397
+ }
398
+
399
+ const policyPath = path.join(gitAiRoot, "filter_plugins.json")
400
+ fs.mkdirSync(path.join(gitAiRoot, "filters"), {recursive: true})
401
+ fs.mkdirSync(path.join(gitAiRoot, "control-panel"), {recursive: true})
402
+ if (!fs.existsSync(policyPath)) {
403
+ fs.copyFileSync(path.join(PROJECT_ROOT, "policy.example.json"), policyPath)
404
+ if (platform !== "win32") {
405
+ fs.chmodSync(policyPath, 0o600)
406
+ }
407
+ }
408
+
409
+ const customMetricsSupported = fs
410
+ .readFileSync(binary)
411
+ .includes(Buffer.from("custom_metrics.json"))
412
+ if (customMetricsSupported) {
413
+ configureCustomMetrics(gitAiRoot)
414
+ fs.writeFileSync(path.join(gitAiRoot, "control-panel", ".custom-metrics-managed"), "")
415
+ console.log("检测到 custom_metrics 定制版:已启用细粒度上报过滤")
416
+ } else {
417
+ fs.rmSync(path.join(gitAiRoot, "control-panel", ".custom-metrics-managed"), {
418
+ force: true,
419
+ })
420
+ console.log("检测到官方上游版:保留原生配置管理,不修改无效的 custom_metrics.json")
421
+ }
422
+
423
+ copyRuntime(gitAiRoot)
424
+ run(pythonCommand[0], [
425
+ ...pythonCommand.slice(1),
426
+ "-m",
427
+ "py_compile",
428
+ path.join(gitAiRoot, "control-panel", "server.py"),
429
+ path.join(gitAiRoot, "filters", "plugin_filter_runtime.py"),
430
+ ])
431
+
432
+ stopServices(platform)
433
+ await Promise.all([waitForPortRelease(38741), waitForPortRelease(38742)])
434
+ writeServiceFiles(platform, gitAiRoot, pythonCommand)
435
+ startServices(platform, gitAiRoot)
436
+ await waitForHttp("http://127.0.0.1:38741/health", "过滤服务")
437
+ await waitForHttp("http://127.0.0.1:38742/api/status", "配置页面")
438
+
439
+ console.log("安装完成:")
440
+ console.log(" 配置页面:http://127.0.0.1:38742")
441
+ console.log(" 过滤服务:http://127.0.0.1:38741/health")
442
+ console.log(` 用户配置:${gitAiRoot}`)
443
+ return {gitAiRoot, platform}
444
+ }
445
+
446
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
447
+ install().catch((error) => {
448
+ console.error(`安装失败:${error.message}`)
449
+ process.exitCode = 1
450
+ })
451
+ }
@@ -2,240 +2,4 @@
2
2
  set -eu
3
3
 
4
4
  PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
5
- GIT_AI_ROOT=${GIT_AI_ROOT:-"${HOME}/.git-ai"}
6
- LAUNCH_AGENTS_ROOT="${HOME}/Library/LaunchAgents"
7
- CONTROL_PANEL_DIR="${GIT_AI_ROOT}/control-panel"
8
- FILTER_DIR="${GIT_AI_ROOT}/filters"
9
- GIT_AI_BINARY="${GIT_AI_ROOT}/bin/git-ai"
10
- POLICY_PATH="${GIT_AI_ROOT}/filter_plugins.json"
11
- METRICS_PATH="${GIT_AI_ROOT}/custom_metrics.json"
12
- UPSTREAMS_PATH="${GIT_AI_ROOT}/upstream_metrics.json"
13
- CUSTOM_METRICS_MARKER="${CONTROL_PANEL_DIR}/.custom-metrics-managed"
14
- CONTROL_PANEL_PLIST="${LAUNCH_AGENTS_ROOT}/com.git-ai.control-panel.plist"
15
- FILTER_PLIST="${LAUNCH_AGENTS_ROOT}/com.git-ai.skill-usage-filter.plist"
16
- CONTROL_PANEL_LABEL="gui/$(id -u)/com.git-ai.control-panel"
17
- FILTER_LABEL="gui/$(id -u)/com.git-ai.skill-usage-filter"
18
-
19
- require_file() {
20
- if [ ! -f "$1" ]; then
21
- echo "缺少安装文件:$1" >&2
22
- exit 1
23
- fi
24
- }
25
-
26
- wait_for_port_release() {
27
- port=$1
28
- attempt=0
29
- while [ "${attempt}" -lt 20 ]; do
30
- if ! /usr/sbin/lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
31
- return 0
32
- fi
33
- attempt=$((attempt + 1))
34
- sleep 0.25
35
- done
36
- echo "端口 ${port} 仍被占用,停止安装" >&2
37
- exit 1
38
- }
39
-
40
- wait_for_http() {
41
- url=$1
42
- label=$2
43
- attempt=0
44
- while [ "${attempt}" -lt 20 ]; do
45
- if curl -fsS "${url}" >/dev/null 2>&1; then
46
- return 0
47
- fi
48
- attempt=$((attempt + 1))
49
- sleep 0.5
50
- done
51
- echo "${label} 启动失败:${url}" >&2
52
- exit 1
53
- }
54
-
55
- require_file "${PROJECT_ROOT}/server.py"
56
- require_file "${PROJECT_ROOT}/static/index.html"
57
- require_file "${PROJECT_ROOT}/plugins/upload-filter/plugin_filter_runtime.py"
58
- require_file "${PROJECT_ROOT}/policy.example.json"
59
- require_file "${PROJECT_ROOT}/launchagents/com.git-ai.control-panel.plist.in"
60
- require_file "${PROJECT_ROOT}/launchagents/com.git-ai.skill-usage-filter.plist.in"
61
- require_file "${GIT_AI_BINARY}"
62
-
63
- mkdir -p "${CONTROL_PANEL_DIR}" "${FILTER_DIR}" "${LAUNCH_AGENTS_ROOT}"
64
-
65
- if [ ! -f "${POLICY_PATH}" ]; then
66
- install -m 600 "${PROJECT_ROOT}/policy.example.json" "${POLICY_PATH}"
67
- fi
68
-
69
- if /usr/bin/strings "${GIT_AI_BINARY}" | grep -Fq "custom_metrics.json"; then
70
- /usr/bin/python3 - \
71
- "${METRICS_PATH}" \
72
- "${UPSTREAMS_PATH}" \
73
- "${FILTER_DIR}/plugin_filter_runtime.py" <<'PY'
74
- import ast
75
- import json
76
- import os
77
- import sys
78
- import tempfile
79
- from pathlib import Path
80
- from urllib.parse import urlsplit
81
-
82
- path = Path(sys.argv[1])
83
- upstreams_path = Path(sys.argv[2])
84
- installed_filter_path = Path(sys.argv[3])
85
-
86
-
87
- def is_external_http_url(value):
88
- if not isinstance(value, str):
89
- return False
90
- parsed = urlsplit(value)
91
- return (
92
- parsed.scheme in {"http", "https"}
93
- and bool(parsed.netloc)
94
- and parsed.hostname not in {"127.0.0.1", "localhost", "::1"}
95
- )
96
-
97
-
98
- def write_json(destination, payload):
99
- destination.parent.mkdir(parents=True, exist_ok=True)
100
- descriptor, temporary_name = tempfile.mkstemp(
101
- prefix=f".{destination.name}.",
102
- suffix=".tmp",
103
- dir=destination.parent,
104
- )
105
- try:
106
- with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
107
- json.dump(payload, handle, ensure_ascii=False, indent=2)
108
- handle.write("\n")
109
- handle.flush()
110
- os.fsync(handle.fileno())
111
- os.chmod(temporary_name, 0o600)
112
- os.replace(temporary_name, destination)
113
- except Exception:
114
- try:
115
- os.unlink(temporary_name)
116
- except FileNotFoundError:
117
- pass
118
- raise
119
-
120
-
121
- try:
122
- config = json.loads(path.read_text(encoding="utf-8"))
123
- except FileNotFoundError:
124
- config = {}
125
-
126
- if upstreams_path.exists():
127
- saved_upstreams = json.loads(upstreams_path.read_text(encoding="utf-8"))
128
- saved_routes = saved_upstreams.get("routes", saved_upstreams)
129
- if not isinstance(saved_routes, dict) or not any(
130
- is_external_http_url(url) for url in saved_routes.values()
131
- ):
132
- raise SystemExit("原上报端点备份无效,已停止安装")
133
- os.chmod(upstreams_path, 0o600)
134
- else:
135
- route_keys = {
136
- "/legacy/commit": "commit_endpoint",
137
- "/legacy/checkpoint": "checkpoint_endpoint",
138
- "/legacy/token-usage": "token_usage_endpoint",
139
- "/prompt-duration": "prompt_duration_endpoint",
140
- "/commit": "commit_endpoint_v2",
141
- "/checkpoint": "checkpoint_endpoint_v2",
142
- "/token-usage": "token_usage_endpoint_v2",
143
- "/skill-usage": "skill_usage_endpoint_v2",
144
- "/agent-usage": "agent_usage_endpoint_v2",
145
- "/prompt-report": "prompt_report_endpoint_v2",
146
- }
147
- routes = {
148
- route: config[key]
149
- for route, key in route_keys.items()
150
- if is_external_http_url(config.get(key))
151
- }
152
- token_url = routes.get("/token-usage")
153
- if token_url:
154
- routes["/token-usage/batch"] = f"{token_url.rstrip('/')}/batch"
155
- routes["/token-usage/stats"] = f"{token_url.rstrip('/')}/stats"
156
-
157
- if not routes and installed_filter_path.exists():
158
- try:
159
- module = ast.parse(installed_filter_path.read_text(encoding="utf-8"))
160
- for statement in module.body:
161
- if not isinstance(statement, ast.Assign):
162
- continue
163
- if not any(
164
- isinstance(target, ast.Name) and target.id == "UPSTREAMS"
165
- for target in statement.targets
166
- ):
167
- continue
168
- legacy_routes = ast.literal_eval(statement.value)
169
- routes = {
170
- route: url
171
- for route, url in legacy_routes.items()
172
- if is_external_http_url(url)
173
- }
174
- break
175
- except (OSError, SyntaxError, ValueError):
176
- routes = {}
177
-
178
- if not routes:
179
- raise SystemExit(
180
- "无法从 custom_metrics.json 识别原上报端点;"
181
- "为避免丢失或回环请求,已停止安装"
182
- )
183
- write_json(upstreams_path, {"version": 1, "routes": routes})
184
-
185
- config.update(
186
- {
187
- "enable": True,
188
- "commit_endpoint": "http://127.0.0.1:38741/legacy/commit",
189
- "checkpoint_endpoint": "http://127.0.0.1:38741/legacy/checkpoint",
190
- "token_usage_endpoint": "http://127.0.0.1:38741/legacy/token-usage",
191
- "prompt_duration_endpoint": "http://127.0.0.1:38741/prompt-duration",
192
- "commit_endpoint_v2": "http://127.0.0.1:38741/commit",
193
- "checkpoint_endpoint_v2": "http://127.0.0.1:38741/checkpoint",
194
- "token_usage_endpoint_v2": "http://127.0.0.1:38741/token-usage",
195
- "skill_usage_endpoint_v2": "http://127.0.0.1:38741/skill-usage",
196
- "agent_usage_endpoint_v2": "http://127.0.0.1:38741/agent-usage",
197
- "prompt_report_endpoint_v2": "http://127.0.0.1:38741/prompt-report",
198
- }
199
- )
200
-
201
- write_json(path, config)
202
- PY
203
- touch "${CUSTOM_METRICS_MARKER}"
204
- echo "检测到 custom_metrics 定制版:已启用细粒度上报过滤"
205
- else
206
- rm -f "${CUSTOM_METRICS_MARKER}"
207
- echo "检测到官方上游版:保留原生配置管理,不修改无效的 custom_metrics.json"
208
- fi
209
-
210
- install -m 700 "${PROJECT_ROOT}/server.py" "${CONTROL_PANEL_DIR}/server.py"
211
- install -m 700 \
212
- "${PROJECT_ROOT}/plugins/upload-filter/plugin_filter_runtime.py" \
213
- "${FILTER_DIR}/plugin_filter_runtime.py"
214
- /usr/bin/rsync -a --delete "${PROJECT_ROOT}/static/" "${CONTROL_PANEL_DIR}/static/"
215
-
216
- sed "s#__GIT_AI_ROOT__#${GIT_AI_ROOT}#g" \
217
- "${PROJECT_ROOT}/launchagents/com.git-ai.control-panel.plist.in" \
218
- > "${CONTROL_PANEL_PLIST}"
219
- sed "s#__GIT_AI_ROOT__#${GIT_AI_ROOT}#g" \
220
- "${PROJECT_ROOT}/launchagents/com.git-ai.skill-usage-filter.plist.in" \
221
- > "${FILTER_PLIST}"
222
- chmod 600 "${CONTROL_PANEL_PLIST}" "${FILTER_PLIST}"
223
-
224
- plutil -lint "${CONTROL_PANEL_PLIST}" "${FILTER_PLIST}"
225
- /usr/bin/python3 -m py_compile \
226
- "${CONTROL_PANEL_DIR}/server.py" \
227
- "${FILTER_DIR}/plugin_filter_runtime.py"
228
-
229
- launchctl bootout "${CONTROL_PANEL_LABEL}" >/dev/null 2>&1 || true
230
- launchctl bootout "${FILTER_LABEL}" >/dev/null 2>&1 || true
231
- wait_for_port_release 38741
232
- wait_for_port_release 38742
233
- launchctl bootstrap "gui/$(id -u)" "${FILTER_PLIST}"
234
- launchctl bootstrap "gui/$(id -u)" "${CONTROL_PANEL_PLIST}"
235
- wait_for_http "http://127.0.0.1:38741/health" "过滤服务"
236
- wait_for_http "http://127.0.0.1:38742/api/status" "配置页面"
237
-
238
- echo "安装完成:"
239
- echo " 配置页面:http://127.0.0.1:38742"
240
- echo " 过滤服务:http://127.0.0.1:38741/health"
241
- echo " 用户配置:${GIT_AI_ROOT}"
5
+ exec node "${PROJECT_ROOT}/scripts/install.mjs"