@wax0629/pi-manager 0.1.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,178 @@
1
+ import { upstreamUrl } from "./gateway.mjs";
2
+
3
+ const ERROR_CODES = {
4
+ DNS: new Set(["ENOTFOUND", "EAI_AGAIN"]),
5
+ TLS: new Set(["CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE"]),
6
+ TIMEOUT: new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "ETIMEDOUT"]),
7
+ NETWORK: new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"])
8
+ };
9
+
10
+ export const CONNECTION_TEST_CATEGORIES = Object.freeze({
11
+ SUCCESS: "success",
12
+ AUTH: "auth",
13
+ NOT_FOUND: "not_found",
14
+ RATE_LIMIT: "rate_limit",
15
+ TIMEOUT: "timeout",
16
+ DNS: "dns",
17
+ TLS: "tls",
18
+ NETWORK: "network",
19
+ PROTOCOL: "protocol",
20
+ SUBSCRIPTION: "subscription",
21
+ UNKNOWN: "unknown"
22
+ });
23
+
24
+ function errorCode(error) {
25
+ return String(error?.code || error?.cause?.code || "");
26
+ }
27
+
28
+ function errorMessage(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+
32
+ export function sanitizeConnectionTestUrl(value) {
33
+ try {
34
+ const url = new URL(String(value || ""));
35
+ url.username = "";
36
+ url.password = "";
37
+ url.search = "";
38
+ url.hash = "";
39
+ return url.toString();
40
+ } catch {
41
+ return String(value || "");
42
+ }
43
+ }
44
+
45
+ function normalizeResult({ provider, ok, category, message, detail, status, testedAt, durationMs }) {
46
+ return {
47
+ providerId: provider.id,
48
+ providerName: provider.name,
49
+ ok,
50
+ category,
51
+ message,
52
+ detail,
53
+ status,
54
+ testedAt,
55
+ durationMs
56
+ };
57
+ }
58
+
59
+ export function classifyConnectionError(error) {
60
+ const code = errorCode(error);
61
+ const message = errorMessage(error);
62
+ if (ERROR_CODES.DNS.has(code) || /ENOTFOUND|EAI_AGAIN/i.test(message)) {
63
+ return { category: CONNECTION_TEST_CATEGORIES.DNS, message: `DNS 解析失败:${message}` };
64
+ }
65
+ if (ERROR_CODES.TLS.has(code) || /TLS|SSL|certificate/i.test(message)) {
66
+ return { category: CONNECTION_TEST_CATEGORIES.TLS, message: `TLS/证书校验失败:${message}` };
67
+ }
68
+ if (ERROR_CODES.TIMEOUT.has(code) || /timeout|timed out/i.test(message)) {
69
+ return { category: CONNECTION_TEST_CATEGORIES.TIMEOUT, message: `请求超时:${message}` };
70
+ }
71
+ if (ERROR_CODES.NETWORK.has(code) || /connect|refused|unreachable|reset/i.test(message)) {
72
+ return { category: CONNECTION_TEST_CATEGORIES.NETWORK, message: `网络连接失败:${message}` };
73
+ }
74
+ if (/Invalid URL|unsupported protocol|protocol/i.test(message)) {
75
+ return { category: CONNECTION_TEST_CATEGORIES.PROTOCOL, message: `协议或地址无效:${message}` };
76
+ }
77
+ return { category: CONNECTION_TEST_CATEGORIES.UNKNOWN, message: `连接测试失败:${message}` };
78
+ }
79
+
80
+ function classifyHttpStatus(status) {
81
+ if (status === 401 || status === 403) {
82
+ return { category: CONNECTION_TEST_CATEGORIES.AUTH, message: `认证失败(HTTP ${status})` };
83
+ }
84
+ if (status === 404) {
85
+ return { category: CONNECTION_TEST_CATEGORIES.NOT_FOUND, message: "模型端点不存在(HTTP 404)" };
86
+ }
87
+ if (status === 429) {
88
+ return { category: CONNECTION_TEST_CATEGORIES.RATE_LIMIT, message: "触发限流(HTTP 429)" };
89
+ }
90
+ if (status >= 500) {
91
+ return { category: CONNECTION_TEST_CATEGORIES.PROTOCOL, message: `上游服务异常(HTTP ${status})` };
92
+ }
93
+ return { category: CONNECTION_TEST_CATEGORIES.PROTOCOL, message: `返回了非预期状态(HTTP ${status})` };
94
+ }
95
+
96
+ export async function testProviderConnection({ provider, credential, detectPi, fetchImpl = fetch, timeoutMs = 4000 }) {
97
+ const testedAt = new Date().toISOString();
98
+ const startedAt = Date.now();
99
+
100
+ if (provider.kind === "native-subscription") {
101
+ const piInfo = detectPi(provider.piProvider || provider.id);
102
+ const ok = Boolean(piInfo.subscriptionReady);
103
+ return normalizeResult({
104
+ provider,
105
+ ok,
106
+ category: ok ? CONNECTION_TEST_CATEGORIES.SUCCESS : CONNECTION_TEST_CATEGORIES.AUTH,
107
+ message: ok ? "Pi 原生认证可用" : "Pi 原生认证未就绪",
108
+ detail: ok ? "已通过 Pi auth check" : "请先完成 /login 或检查 Pi 安装",
109
+ status: ok ? 200 : 401,
110
+ testedAt,
111
+ durationMs: Date.now() - startedAt
112
+ });
113
+ }
114
+
115
+ if (!provider.baseUrl) {
116
+ return normalizeResult({
117
+ provider,
118
+ ok: false,
119
+ category: CONNECTION_TEST_CATEGORIES.PROTOCOL,
120
+ message: "供应商缺少 baseUrl",
121
+ detail: "无法发起连接测试",
122
+ status: 0,
123
+ testedAt,
124
+ durationMs: Date.now() - startedAt
125
+ });
126
+ }
127
+
128
+ const headers = { accept: "application/json" };
129
+ if (credential) headers.authorization = `Bearer ${credential}`;
130
+ const testUrl = upstreamUrl(provider.baseUrl, "models");
131
+ const safeTestUrl = sanitizeConnectionTestUrl(testUrl);
132
+
133
+ let response;
134
+ try {
135
+ response = await fetchImpl(testUrl, {
136
+ method: "GET",
137
+ headers,
138
+ signal: AbortSignal.timeout(timeoutMs)
139
+ });
140
+ } catch (error) {
141
+ const failed = classifyConnectionError(error);
142
+ return normalizeResult({
143
+ provider,
144
+ ok: false,
145
+ category: failed.category,
146
+ message: failed.message,
147
+ detail: safeTestUrl,
148
+ status: 0,
149
+ testedAt,
150
+ durationMs: Date.now() - startedAt
151
+ });
152
+ }
153
+
154
+ if (response.ok) {
155
+ return normalizeResult({
156
+ provider,
157
+ ok: true,
158
+ category: CONNECTION_TEST_CATEGORIES.SUCCESS,
159
+ message: "连接测试通过",
160
+ detail: safeTestUrl,
161
+ status: response.status,
162
+ testedAt,
163
+ durationMs: Date.now() - startedAt
164
+ });
165
+ }
166
+
167
+ const failed = classifyHttpStatus(response.status);
168
+ return normalizeResult({
169
+ provider,
170
+ ok: false,
171
+ category: failed.category,
172
+ message: failed.message,
173
+ detail: `${safeTestUrl} · HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`,
174
+ status: response.status,
175
+ testedAt,
176
+ durationMs: Date.now() - startedAt
177
+ });
178
+ }
@@ -0,0 +1,123 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const KEYCHAIN_SERVICE = "pi-manager";
6
+
7
+ function canUseKeychain() {
8
+ return process.platform === "darwin" && process.env.PI_MANAGER_DISABLE_KEYCHAIN !== "1";
9
+ }
10
+
11
+ function fallbackPath(dataDir) {
12
+ return path.join(dataDir, "credentials.json");
13
+ }
14
+
15
+ function readFallback(dataDir) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(fallbackPath(dataDir), "utf8"));
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ function writeFallback(dataDir, values) {
24
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
25
+ const target = fallbackPath(dataDir);
26
+ const temp = `${target}.${process.pid}.tmp`;
27
+ fs.writeFileSync(temp, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
28
+ fs.renameSync(temp, target);
29
+ try {
30
+ fs.chmodSync(target, 0o600);
31
+ } catch {
32
+ // Best effort on filesystems without POSIX permissions.
33
+ }
34
+ }
35
+
36
+ export function readDotEnvValue(filePath, key) {
37
+ try {
38
+ const source = fs.readFileSync(filePath, "utf8");
39
+ for (const line of source.split(/\r?\n/)) {
40
+ const match = line.match(new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*=\\s*(.*)\\s*$`));
41
+ if (!match) continue;
42
+ const value = match[1].trim();
43
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
44
+ return value.slice(1, -1);
45
+ }
46
+ return value;
47
+ }
48
+ } catch {
49
+ // Missing bridge or env file is a normal unconfigured state.
50
+ }
51
+ return "";
52
+ }
53
+
54
+ export function getStoredSecret({ dataDir, providerId }) {
55
+ if (canUseKeychain()) {
56
+ try {
57
+ const stored = execFileSync("security", ["find-generic-password", "-a", providerId, "-s", KEYCHAIN_SERVICE, "-w"], {
58
+ encoding: "utf8",
59
+ stdio: ["ignore", "pipe", "ignore"]
60
+ }).trim();
61
+ if (stored) return stored;
62
+ } catch {
63
+ // Fall through to the local permission-hardened file.
64
+ }
65
+ }
66
+ return readFallback(dataDir)[providerId] || "";
67
+ }
68
+
69
+ export function getSecret({ dataDir, provider }) {
70
+ const envValue = provider.credentialEnv ? process.env[provider.credentialEnv] : "";
71
+ if (envValue) return envValue;
72
+
73
+ const stored = getStoredSecret({ dataDir, providerId: provider.id });
74
+ if (stored) return stored;
75
+
76
+ if (provider.id === "antigravity" && provider.bridgePath) {
77
+ return readDotEnvValue(path.join(provider.bridgePath, ".env"), provider.credentialEnv || "API_KEY");
78
+ }
79
+ return "";
80
+ }
81
+
82
+ export function setSecret({ dataDir, providerId, value }) {
83
+ const normalized = String(value || "").trim();
84
+ if (!normalized) throw new Error("凭据不能为空");
85
+
86
+ if (canUseKeychain()) {
87
+ try {
88
+ execFileSync("security", ["add-generic-password", "-a", providerId, "-s", KEYCHAIN_SERVICE, "-w", normalized, "-U"], {
89
+ stdio: ["ignore", "ignore", "pipe"]
90
+ });
91
+ return { storage: "keychain" };
92
+ } catch {
93
+ // Use the permission-hardened fallback if Keychain is unavailable.
94
+ }
95
+ }
96
+
97
+ const values = readFallback(dataDir);
98
+ values[providerId] = normalized;
99
+ writeFallback(dataDir, values);
100
+ return { storage: "local-file" };
101
+ }
102
+
103
+ export function deleteSecret({ dataDir, providerId }) {
104
+ if (canUseKeychain()) {
105
+ try {
106
+ execFileSync("security", ["delete-generic-password", "-a", providerId, "-s", KEYCHAIN_SERVICE], {
107
+ stdio: ["ignore", "ignore", "ignore"]
108
+ });
109
+ } catch {
110
+ // It is fine when the item does not exist.
111
+ }
112
+ }
113
+
114
+ const values = readFallback(dataDir);
115
+ if (Object.hasOwn(values, providerId)) {
116
+ delete values[providerId];
117
+ writeFallback(dataDir, values);
118
+ }
119
+ }
120
+
121
+ export function hasSecret(options) {
122
+ return Boolean(getSecret(options));
123
+ }