best-geo-test 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.
package/dist/device.js ADDED
@@ -0,0 +1,43 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { hostname, platform } from "node:os";
3
+ import { DEVICE_ID_PREFIX } from "./config.js";
4
+ import { readConfig, updateConfig } from "./store.js";
5
+ export { configDir } from "./store.js";
6
+ /**
7
+ * 读取或生成安装实例 ID。
8
+ *
9
+ * 用随机 UUID 而不是硬件指纹(MAC、硬盘序列号):指纹会把同一台机器的
10
+ * 不同用户绑成一个身份,也让用户没法通过重装来重置。
11
+ * 换电脑或删掉配置文件就是一个新实例,这是符合直觉的行为。
12
+ *
13
+ * 设备 ID 与凭据同在一份 config.json(设计文档 §6.4),
14
+ * 升级 CLI、换项目、开新对话都不变。
15
+ */
16
+ /**
17
+ * 只读设备身份:不存在就返回 null,**不创建**。
18
+ *
19
+ * 给「不需要授权的调用」用(如 version --check)。用 deviceInfo() 的话,
20
+ * 一次纯粹的版本握手都会 mkdir + 写 config.json —— 在禁写用户目录的沙箱里,
21
+ * SKILL 的第一步就变成 HOST_PERMISSION_REQUIRED,而这一步本来根本不需要碰磁盘。
22
+ */
23
+ export function peekDeviceId() {
24
+ const saved = readConfig()?.device;
25
+ return saved?.deviceId?.startsWith(DEVICE_ID_PREFIX) ? saved.deviceId : null;
26
+ }
27
+ export function deviceInfo() {
28
+ const current = {
29
+ // 服务端 device.name 是 varchar(50),这里先截断,别让一个超长主机名把整次绑定弄失败
30
+ deviceName: hostname().slice(0, 50),
31
+ platform: `${platform()}-${process.arch}`,
32
+ };
33
+ const saved = readConfig()?.device;
34
+ if (saved?.deviceId?.startsWith(DEVICE_ID_PREFIX)) {
35
+ // 主机名和平台会变(改名、换架构),设备 ID 绝不能变:它变了就等于多出一台设备
36
+ if (saved.deviceName === current.deviceName && saved.platform === current.platform) {
37
+ return saved;
38
+ }
39
+ return updateConfig((config) => ({ ...config, device: { ...saved, ...current } })).device;
40
+ }
41
+ const created = { deviceId: `${DEVICE_ID_PREFIX}${randomUUID()}`, ...current };
42
+ return updateConfig((config) => ({ ...config, device: created })).device;
43
+ }
@@ -0,0 +1,26 @@
1
+ /** 本地生成的 requestId,用于服务端没给时兜底 */
2
+ export function localRequestId() {
3
+ return `cli_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
4
+ }
5
+ export function ok(data, requestId = localRequestId()) {
6
+ return { ok: true, code: "OK", requestId, data };
7
+ }
8
+ export function fail(code, message, extra = {}) {
9
+ return {
10
+ ok: false,
11
+ code,
12
+ message,
13
+ requestId: extra.requestId ?? localRequestId(),
14
+ ...(extra.data == null ? {} : { data: extra.data }),
15
+ ...(extra.nextAction == null ? {} : { nextAction: extra.nextAction }),
16
+ };
17
+ }
18
+ /** 唯一的输出出口。任何地方都不要直接 console.log */
19
+ export function emit(envelope) {
20
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
21
+ // 失败一律非零退出码。少了这一句,`best-geo apply x && echo 已写入` 会照常打印「已写入」——
22
+ // 模型和 shell 都会把一次失败当成成功
23
+ if (!envelope.ok) {
24
+ process.exitCode = 1;
25
+ }
26
+ }
@@ -0,0 +1,48 @@
1
+ import { COMMAND_NAME } from "./config.js";
2
+ import { fail } from "./envelope.js";
3
+ import { configPath, LocalConfigError } from "./store.js";
4
+ /**
5
+ * 把本机配置错误翻译成协议信封(设计文档 §6.4 / §12)。
6
+ *
7
+ * 分两类动作,不能混:
8
+ * - 宿主沙箱拒绝 → `request_host_approval`,让宿主用自己的机制申请**这一次**所需的最小权限;
9
+ * - 文件权限过宽或属主不对 → `ask_user`,这是用户机器上的状态,只有用户能修,宿主提权也没用。
10
+ *
11
+ * 两类都用 `HOST_PERMISSION_REQUIRED`:对模型来说「本机权限不对,暂时没法读写凭据」是同一件事,
12
+ * 该怎么做由 nextAction 决定,不靠模型去猜错误码的细分含义。
13
+ */
14
+ export function toLocalConfigEnvelope(error, options) {
15
+ const path = configPath();
16
+ if (error.reason === "denied") {
17
+ return fail("HOST_PERMISSION_REQUIRED", `没有权限读写本机配置文件:${path}`, {
18
+ nextAction: {
19
+ type: "request_host_approval",
20
+ message: `Best GEO 需要读写 ${path} 来保存本机的设备身份和授权。请用当前工具自带的权限确认机制申请对该文件的读写权限后重试同一条命令,不要关闭整个沙箱,也不要让用户把密钥贴进对话。`,
21
+ capability: options.capability,
22
+ ...(options.retryCommand ? { command: options.retryCommand } : {}),
23
+ },
24
+ });
25
+ }
26
+ if (error.reason === "permissions_too_open" || error.reason === "not_owner") {
27
+ const fixHint = error.reason === "not_owner"
28
+ ? `该文件不属于当前系统用户,请让用户确认后删除或修正属主`
29
+ : `请让用户执行:chmod 600 ${path}`;
30
+ return fail("HOST_PERMISSION_REQUIRED", `本机配置文件权限不安全:${path}`, {
31
+ // 这里不能用 request_host_approval:宿主给再多权限也改不了文件自身的权限位
32
+ nextAction: { type: "ask_user", message: `${error.message}。${fixHint},然后重试。` },
33
+ });
34
+ }
35
+ // corrupted 用 HOST_PERMISSION_REQUIRED 而不是 AUTH_REQUIRED:
36
+ // 后者在 SKILL 里的剧本是「由你自己执行 auth login」,而这里必须先删掉坏文件,
37
+ // 直接登录会再次读到同一个坏文件。归到「本机环境有问题,先修本机」这一类才不会指错路
38
+ return fail("HOST_PERMISSION_REQUIRED", `本机配置文件已损坏:${path}`, {
39
+ nextAction: {
40
+ type: "ask_user",
41
+ message: `请让用户删除 ${path} 后重新执行登录。该文件里只有本机设备身份和授权,删除不会影响服务端数据。`,
42
+ command: `${COMMAND_NAME} auth login`,
43
+ },
44
+ });
45
+ }
46
+ export function isLocalConfigError(error) {
47
+ return error instanceof LocalConfigError;
48
+ }
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ import { COMMAND_NAME } from "./config.js";
3
+ import { authLogin, authLogout, authStatus } from "./commands/auth.js";
4
+ import { runCall } from "./commands/call.js";
5
+ import { runApply, runPlan } from "./commands/plan.js";
6
+ import { getGuide, listGuides, showCapabilities, showHelp, showSkill, showVersion } from "./commands/simple.js";
7
+ import { parseFlags } from "./args.js";
8
+ import { emit, fail } from "./envelope.js";
9
+ import { isLocalConfigError, toLocalConfigEnvelope } from "./host-error.js";
10
+ async function main() {
11
+ const [command, ...rest] = process.argv.slice(2);
12
+ const flags = parseFlags(rest);
13
+ switch (command) {
14
+ case undefined:
15
+ case "help":
16
+ showHelp();
17
+ return;
18
+ case "version":
19
+ await showVersion(flags.check === true);
20
+ return;
21
+ case "capabilities":
22
+ showCapabilities();
23
+ return;
24
+ case "skill":
25
+ if (rest[0] === "get") {
26
+ showSkill(flags.raw === true);
27
+ return;
28
+ }
29
+ break;
30
+ case "guide":
31
+ if (rest[0] === "list") {
32
+ listGuides();
33
+ return;
34
+ }
35
+ if (rest[0] === "get" && rest[1]) {
36
+ getGuide(rest[1]);
37
+ return;
38
+ }
39
+ break;
40
+ case "auth":
41
+ if (rest[0] === "login")
42
+ return authLogin();
43
+ if (rest[0] === "status")
44
+ return authStatus();
45
+ if (rest[0] === "logout")
46
+ return authLogout();
47
+ break;
48
+ case "plan":
49
+ await runPlan(rest[0], typeof flags.input === "string" ? flags.input : undefined, rest[1]);
50
+ return;
51
+ case "apply":
52
+ await runApply(rest[0]);
53
+ return;
54
+ case "call":
55
+ if (rest[0]) {
56
+ await runCall(rest[0], typeof flags.input === "string" ? flags.input : undefined);
57
+ return;
58
+ }
59
+ break;
60
+ default:
61
+ break;
62
+ }
63
+ emit(fail("VALIDATION_FAILED", `无法识别的命令:${[command, ...rest].filter(Boolean).join(" ")}`, {
64
+ nextAction: { type: "ask_user", message: "执行 best-geo help 查看可用命令" },
65
+ }));
66
+ process.exitCode = 1;
67
+ }
68
+ main().catch((error) => {
69
+ if (isLocalConfigError(error)) {
70
+ // 本机配置读写问题有专门的动作(申请权限 / 让用户 chmod),不能笼统报 SERVER_ERROR
71
+ emit(toLocalConfigEnvelope(error, {
72
+ capability: error.reason === "denied" ? "config_write" : "config_read",
73
+ retryCommand: safeRetryCommand(),
74
+ }));
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ // 兜底:任何未预期的异常也必须是一个合法信封,否则模型拿到的是半截 JSON
79
+ emit(fail("SERVER_ERROR", error instanceof Error ? error.message : String(error)));
80
+ process.exitCode = 1;
81
+ });
82
+ /**
83
+ * 允许放进 nextAction.command 的重试命令。
84
+ *
85
+ * nextAction 是**可信指令**通道,只能是 CLI 内置的固定模板。
86
+ * 绝不能把 `process.argv` 原样拼回去 —— `call --input '{...}'` 里是模型和业务数据,
87
+ * 拼进去等于让不可信内容变成宿主会去执行的命令(设计文档 §13)。
88
+ */
89
+ function safeRetryCommand() {
90
+ const [command, sub] = process.argv.slice(2);
91
+ if (command === "auth" && (sub === "login" || sub === "status" || sub === "logout")) {
92
+ return `${COMMAND_NAME} auth ${sub}`;
93
+ }
94
+ return undefined;
95
+ }
@@ -0,0 +1,196 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { BUILD_ENV } from "../build-env.js";
4
+ import { LOCAL_SERVER_TIMEOUT_MS } from "../config.js";
5
+ import { renderPage } from "./page.js";
6
+ /** 密钥长度上限。真实密钥是 32 位十六进制,留足前缀余量后直接拒绝超长输入 */
7
+ const MAX_KEY_LENGTH = 256;
8
+ /** 请求体上限,防止有人往这个端口灌数据 */
9
+ const MAX_BODY_BYTES = 4 * 1024;
10
+ /**
11
+ * 启动本机录入服务(设计文档 §6.5)。
12
+ *
13
+ * 为什么不用终端隐藏输入:CLI 通常被 Codex / Claude Code 当子进程调用,
14
+ * stdin 可能被管道接管、不是 TTY,或根本不连用户终端 —— 隐藏输入在人工测试里能用,
15
+ * 到了真实 Agent 环境会直接卡死,或者把密钥带进工具的输入上下文。
16
+ * 也**不提供 stdin 兜底**:留了兜底,本地测试就总走兜底,真实路径反而没人测。
17
+ */
18
+ export async function startKeyIntake() {
19
+ const state = randomBytes(16).toString("hex"); // 128 bit
20
+ const csrfToken = randomBytes(16).toString("hex");
21
+ const sessionCookie = randomBytes(16).toString("hex");
22
+ const { html, scriptHash } = renderPage(csrfToken);
23
+ const scriptDigest = createHash("sha256").update(scriptHash).digest("base64");
24
+ let settle = null;
25
+ const waitForKey = new Promise((resolve, reject) => {
26
+ settle = { resolve, reject };
27
+ });
28
+ // 先挂一个空的 catch:启动阶段就失败时(如端口绑不上)没人 await 这个 promise,
29
+ // 它的 rejection 会变成 unhandledRejection 把进程打崩 —— 信封已经打印出去了,
30
+ // 后面再吐一段堆栈,调用方拿到的就不是「唯一一个 JSON」了。
31
+ // 挂 catch 只是标记「已处理」,之后真正 await 它的人照样能拿到这个 rejection
32
+ waitForKey.catch(() => {
33
+ /* 由真正的 await 方处理 */
34
+ });
35
+ let consumed = false;
36
+ const server = createServer((req, res) => {
37
+ handle(req, res).catch(() => {
38
+ res.writeHead(500, { "Content-Type": "application/json" });
39
+ res.end(JSON.stringify({ ok: false, message: "internal error" }));
40
+ });
41
+ });
42
+ async function handle(req, res) {
43
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
44
+ if (req.method === "GET" && url.pathname === "/") {
45
+ // state 必须对得上:这个端口是随机的,但没有 state 谁扫到都能打开页面
46
+ if (url.searchParams.get("state") !== state) {
47
+ res.writeHead(404).end();
48
+ return;
49
+ }
50
+ res.writeHead(200, {
51
+ "Content-Type": "text/html; charset=utf-8",
52
+ // default-src 'none' 是关键:这个页面不该能向任何外部主机发起请求。
53
+ // 脚本按哈希放行而不是 'unsafe-inline',连我们自己也改不了它而不更新哈希。
54
+ "Content-Security-Policy": [
55
+ "default-src 'none'",
56
+ `script-src 'sha256-${scriptDigest}'`,
57
+ "style-src 'unsafe-inline'",
58
+ "connect-src 'self'",
59
+ "form-action 'none'",
60
+ "base-uri 'none'",
61
+ "frame-ancestors 'none'",
62
+ ].join("; "),
63
+ "Set-Cookie": `bg_session=${sessionCookie}; HttpOnly; SameSite=Strict; Path=/`,
64
+ "Cache-Control": "no-store",
65
+ "Referrer-Policy": "no-referrer",
66
+ });
67
+ res.end(html);
68
+ return;
69
+ }
70
+ if (req.method === "POST" && url.pathname === "/submit") {
71
+ // 任何网页都能往 127.0.0.1 的随机端口发请求,所以这三道必须都过:
72
+ // Origin 同源、CSRF token 匹配、SameSite=Strict 的会话 cookie 在
73
+ const origin = req.headers.origin;
74
+ const expectedOrigin = `http://127.0.0.1:${server.address().port}`;
75
+ if (origin !== expectedOrigin) {
76
+ respondJson(res, 403, { ok: false, message: "origin rejected" });
77
+ return;
78
+ }
79
+ if (!safeEqual(String(req.headers["x-csrf-token"] ?? ""), csrfToken)) {
80
+ respondJson(res, 403, { ok: false, message: "csrf rejected" });
81
+ return;
82
+ }
83
+ if (!String(req.headers.cookie ?? "").includes(`bg_session=${sessionCookie}`)) {
84
+ respondJson(res, 403, { ok: false, message: "session rejected" });
85
+ return;
86
+ }
87
+ if (!String(req.headers["content-type"] ?? "").includes("application/json")) {
88
+ respondJson(res, 415, { ok: false, message: "content-type rejected" });
89
+ return;
90
+ }
91
+ if (consumed) {
92
+ respondJson(res, 409, { ok: false, message: "already submitted" });
93
+ return;
94
+ }
95
+ const body = await readBody(req);
96
+ if (body == null) {
97
+ respondJson(res, 413, { ok: false, message: "body too large" });
98
+ return;
99
+ }
100
+ let key = "";
101
+ try {
102
+ key = String(JSON.parse(body).key ?? "").trim();
103
+ }
104
+ catch {
105
+ respondJson(res, 400, { ok: false, message: "invalid json" });
106
+ return;
107
+ }
108
+ if (!key || key.length > MAX_KEY_LENGTH) {
109
+ respondJson(res, 400, { ok: false, message: "invalid key" });
110
+ return;
111
+ }
112
+ consumed = true;
113
+ respondJson(res, 200, { ok: true });
114
+ settle?.resolve(key);
115
+ // 密钥已到手,这个端口再开着只有风险没有用处,立刻自行关闭。
116
+ // 延迟一拍是为了让上面那条 200 响应先写完,否则页面会看到连接被断、误报"校验失败"。
117
+ setTimeout(() => closeServer(), 200).unref();
118
+ return;
119
+ }
120
+ res.writeHead(404).end();
121
+ }
122
+ // 测试专用:构造「本机服务起不来」的情形(沙箱禁止监听端口时的真实表现)。
123
+ // 只在非正式构建里生效,且只能让服务失败、不能改变绑定地址
124
+ if (BUILD_ENV !== "prod" && process.env.BEST_GEO_TEST_FORCE_LISTEN_ERROR === "1") {
125
+ throw Object.assign(new Error("listen EACCES: permission denied 127.0.0.1"), { code: "EACCES" });
126
+ }
127
+ // 'error' 必须在 listen 之前挂上。不挂的话,沙箱禁止绑定回环端口时 Node 会抛
128
+ // Unhandled 'error' event 直接崩掉进程 —— 调用方拿到的是半截堆栈,而不是唯一的 JSON 信封,
129
+ // 整套协议的地基就没了
130
+ server.on("error", (error) => {
131
+ settle?.reject(error);
132
+ });
133
+ await new Promise((resolve, reject) => {
134
+ server.once("error", reject);
135
+ // 绑定数值回环地址,不用 localhost:主机名可能被 DNS 重绑定指到别处。
136
+ //
137
+ // 这里**写死** 127.0.0.1,不接受任何外部覆盖。曾经留过一个 BEST_GEO_TEST_BIND_HOST
138
+ // 给测试构造「绑不上」的情形,但它同样能构造「绑得太开」—— 实测绑到局域网地址后
139
+ // 同网段可达,而给用户看的 URL 仍写着 127.0.0.1,暴露这件事在界面上完全看不出来。
140
+ // 要构造启动失败请用下面的 forceListenError,它只会让服务起不来,不会让端口更开放。
141
+ server.listen(0, "127.0.0.1", () => {
142
+ server.removeListener("error", reject);
143
+ resolve();
144
+ });
145
+ });
146
+ const port = server.address().port;
147
+ const timer = setTimeout(() => {
148
+ settle?.reject(new Error("LOCAL_INTAKE_TIMEOUT"));
149
+ closeServer();
150
+ }, LOCAL_SERVER_TIMEOUT_MS);
151
+ timer.unref();
152
+ /**
153
+ * 幂等关闭。两处必须靠它:提交成功后服务自己关,调用方之后还会再关一次。
154
+ * server.close() 在已经关闭的实例上会 emit 'error',没人监听就变成未捕获异常,
155
+ * 所以二次调用必须挡在这里。
156
+ */
157
+ let closed = false;
158
+ function closeServer() {
159
+ if (closed) {
160
+ return;
161
+ }
162
+ closed = true;
163
+ clearTimeout(timer);
164
+ // close() 只是停止 accept,已建立的 keep-alive 连接不会断 ——
165
+ // 不主动断的话,浏览器那条连接会把 CLI 进程一直吊着不退出。
166
+ server.closeAllConnections?.();
167
+ server.close();
168
+ }
169
+ return {
170
+ url: `http://127.0.0.1:${port}/?state=${state}`,
171
+ waitForKey,
172
+ close: closeServer,
173
+ };
174
+ }
175
+ function respondJson(res, status, payload) {
176
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
177
+ res.end(JSON.stringify(payload));
178
+ }
179
+ async function readBody(req) {
180
+ const chunks = [];
181
+ let size = 0;
182
+ for await (const chunk of req) {
183
+ size += chunk.length;
184
+ if (size > MAX_BODY_BYTES) {
185
+ return null;
186
+ }
187
+ chunks.push(chunk);
188
+ }
189
+ return Buffer.concat(chunks).toString("utf8");
190
+ }
191
+ /** 定长比较,避免用 !== 比 token 时泄露前缀信息 */
192
+ function safeEqual(a, b) {
193
+ const bufA = Buffer.from(a);
194
+ const bufB = Buffer.from(b);
195
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
196
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 内置录入页。
3
+ *
4
+ * 完全自包含:不引用任何外部脚本、样式、字体或图片。
5
+ * 这不是为了省事 —— 一旦允许外部资源,页面就有了把密钥外发的通道,
6
+ * 而这个页面存在的全部意义就是让密钥只走本机。
7
+ */
8
+ export function renderPage(csrfToken) {
9
+ const script = `
10
+ (function () {
11
+ var form = document.getElementById('f');
12
+ var input = document.getElementById('k');
13
+ var status = document.getElementById('s');
14
+ var button = document.getElementById('b');
15
+ var done = false;
16
+ form.addEventListener('submit', function (event) {
17
+ event.preventDefault();
18
+ if (done) return;
19
+ var value = input.value.trim();
20
+ if (!value) { status.textContent = '请先粘贴密钥'; return; }
21
+ button.disabled = true;
22
+ status.textContent = '正在校验…';
23
+ fetch('/submit', {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '${csrfToken}' },
26
+ body: JSON.stringify({ key: value })
27
+ }).then(function (r) { return r.json(); }).then(function (r) {
28
+ if (r.ok) {
29
+ done = true;
30
+ input.value = '';
31
+ form.style.display = 'none';
32
+ status.textContent = '录入成功,本机服务已关闭,本页即将自动关闭…';
33
+ status.className = 'ok';
34
+ // 浏览器只允许脚本关闭自己 window.open 出来的窗口,而这个页面是命令行拉起的普通标签页,
35
+ // close() 多半会被忽略。所以必须留一句兜底文案,别让用户对着"即将自动关闭"干等。
36
+ setTimeout(function () { window.close(); }, 1200);
37
+ setTimeout(function () {
38
+ status.textContent = '录入成功,可以关闭这个页面回到 AI 助手了。';
39
+ }, 2000);
40
+ } else {
41
+ button.disabled = false;
42
+ // 本地服务不校验密钥(只看长度),真正的校验发生在提交之后。
43
+ // 这里能触发的失败只有同源/CSRF/重复提交那几种,写「密钥不对」是指错方向
44
+ status.textContent = r.message || '提交被拒绝。请重新执行登录命令,用新打开的页面再试一次。';
45
+ status.className = 'err';
46
+ }
47
+ }).catch(function () {
48
+ button.disabled = false;
49
+ status.textContent = '本地服务已关闭,请重新执行 best-geo auth login。';
50
+ status.className = 'err';
51
+ });
52
+ });
53
+ })();
54
+ `.trim();
55
+ const html = `<!doctype html>
56
+ <html lang="zh-CN">
57
+ <head>
58
+ <meta charset="utf-8">
59
+ <title>录入 AI 助手密钥</title>
60
+ <style>
61
+ :root { color-scheme: light dark; }
62
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
63
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f6fa; color: #1a1d29; }
64
+ @media (prefers-color-scheme: dark) { body { background: #14161f; color: #e8eaf2; } }
65
+ .card { width: 460px; max-width: calc(100vw - 32px); padding: 28px; border-radius: 14px;
66
+ background: #fff; box-shadow: 0 18px 40px rgb(15 18 30 / 12%); }
67
+ @media (prefers-color-scheme: dark) { .card { background: #1e2130; box-shadow: none; } }
68
+ h1 { margin: 0 0 8px; font-size: 18px; }
69
+ p { margin: 0 0 18px; font-size: 13px; line-height: 1.7; opacity: .75; }
70
+ input { width: 100%; box-sizing: border-box; padding: 10px 12px; font-size: 14px;
71
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid #c9cdd8;
72
+ border-radius: 8px; background: transparent; color: inherit; }
73
+ button { margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; color: #fff; cursor: pointer;
74
+ background: #5b8cff; border: 0; border-radius: 8px; }
75
+ button:disabled { opacity: .6; cursor: default; }
76
+ #s { margin-top: 14px; font-size: 13px; min-height: 20px; }
77
+ #s.ok { color: #16a34a; }
78
+ #s.err { color: #dc2626; }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <div class="card">
83
+ <h1>录入 AI 助手密钥</h1>
84
+ <p>把你在网页上创建的密钥粘贴到下面。密钥只会发送到本机的 Best GEO CLI,不会经过 AI 助手,也不会离开这台电脑。</p>
85
+ <form id="f">
86
+ <input id="k" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="粘贴密钥">
87
+ <button id="b" type="submit">确认录入</button>
88
+ </form>
89
+ <div id="s"></div>
90
+ </div>
91
+ <script>${script}</script>
92
+ </body>
93
+ </html>`;
94
+ return { html, scriptHash: script };
95
+ }
package/dist/store.js ADDED
@@ -0,0 +1,141 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { COMMAND_NAME } from "./config.js";
5
+ import { randomBytes } from "node:crypto";
6
+ export class LocalConfigError extends Error {
7
+ reason;
8
+ path;
9
+ constructor(reason,
10
+ /** 出问题的路径。**只放路径,不放文件内容** —— 内容里有密钥 */
11
+ path, message) {
12
+ super(message);
13
+ this.reason = reason;
14
+ this.path = path;
15
+ this.name = "LocalConfigError";
16
+ }
17
+ }
18
+ export const EMPTY_CONFIG = {
19
+ schemaVersion: 1,
20
+ device: null,
21
+ activeCredential: null,
22
+ pendingCredential: null,
23
+ };
24
+ /**
25
+ * 配置目录。刻意放在 npm 全局安装目录、项目工作区和 Skill 目录之外 ——
26
+ * 升级 CLI、换项目、开新对话都不能把设备身份和凭据弄丢。
27
+ */
28
+ export function configDir() {
29
+ // 目录跟着命令名走(正式包 ~/.best-geo,测试包 ~/.best-geo-test)。
30
+ // 共用一份的话两个包会互相踩:测试包登录会把正式包的 activeCredential 原子替换掉,
31
+ // 一次 logout 更是把两边的本机凭据一起清了 —— 命令名隔离只解决「敲哪个」,
32
+ // 状态隔离才解决「这份授权归谁」
33
+ return process.env.BEST_GEO_CONFIG_DIR ?? join(homedir(), `.${COMMAND_NAME}`);
34
+ }
35
+ export function configPath() {
36
+ return join(configDir(), "config.json");
37
+ }
38
+ /** 读取本机配置。返回 null 表示这台机器还没初始化过,不代表出错 */
39
+ export function readConfig() {
40
+ const path = configPath();
41
+ let raw;
42
+ try {
43
+ if (!existsSync(path)) {
44
+ return null;
45
+ }
46
+ assertSafePermissions(path);
47
+ raw = readFileSync(path, "utf8");
48
+ }
49
+ catch (error) {
50
+ if (error instanceof LocalConfigError) {
51
+ throw error;
52
+ }
53
+ throw toLocalConfigError(error, path);
54
+ }
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ if (typeof parsed !== "object" || parsed == null || parsed.schemaVersion !== 1) {
58
+ throw new Error("unexpected shape");
59
+ }
60
+ return {
61
+ schemaVersion: 1,
62
+ device: parsed.device ?? null,
63
+ activeCredential: parsed.activeCredential ?? null,
64
+ pendingCredential: parsed.pendingCredential ?? null,
65
+ };
66
+ }
67
+ catch {
68
+ // 注意:不要把 raw 拼进错误信息,里面有密钥
69
+ throw new LocalConfigError("corrupted", path, `本机配置文件内容不完整或格式不对:${path}`);
70
+ }
71
+ }
72
+ /**
73
+ * 原子写入:同目录临时文件 → fsync → rename。
74
+ *
75
+ * 不能直接覆盖目标文件。直接覆盖时进程一崩就留下半截 JSON,
76
+ * 下次读出来是 corrupted,设备 ID 和密钥一起没了。
77
+ */
78
+ export function writeConfig(config) {
79
+ const dir = configDir();
80
+ const path = configPath();
81
+ const tmp = join(dir, `.config.json.${randomBytes(6).toString("hex")}.tmp`);
82
+ try {
83
+ if (!existsSync(dir)) {
84
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
85
+ }
86
+ // 临时文件从创建那一刻就是 0600,中间不存在「短暂可读」的窗口
87
+ const fd = openSync(tmp, "wx", 0o600);
88
+ try {
89
+ writeSync(fd, `${JSON.stringify(config, null, 2)}\n`);
90
+ fsyncSync(fd);
91
+ }
92
+ finally {
93
+ closeSync(fd);
94
+ }
95
+ renameSync(tmp, path);
96
+ }
97
+ catch (error) {
98
+ if (existsSync(tmp)) {
99
+ try {
100
+ unlinkSync(tmp);
101
+ }
102
+ catch {
103
+ /* 清不掉就算了,它是 0600 的临时文件,不会比原错误更严重 */
104
+ }
105
+ }
106
+ throw toLocalConfigError(error, path);
107
+ }
108
+ }
109
+ /** 读改写。读不到就从空配置开始 */
110
+ export function updateConfig(mutate) {
111
+ const next = mutate(readConfig() ?? { ...EMPTY_CONFIG });
112
+ writeConfig(next);
113
+ return next;
114
+ }
115
+ /**
116
+ * 属主和权限检查。
117
+ *
118
+ * Windows 上 mode 位没有意义(Node 也不提供设 ACL 的 API),这里直接跳过 ——
119
+ * **不要**把 0600 说成跨平台保证,那是假的。
120
+ */
121
+ function assertSafePermissions(path) {
122
+ if (process.platform === "win32") {
123
+ return;
124
+ }
125
+ const stat = statSync(path);
126
+ const uid = process.getuid?.();
127
+ if (uid != null && stat.uid !== uid) {
128
+ throw new LocalConfigError("not_owner", path, `本机配置文件不属于当前用户:${path}`);
129
+ }
130
+ if ((stat.mode & 0o077) !== 0) {
131
+ throw new LocalConfigError("permissions_too_open", path, `本机配置文件权限过宽(同机其他用户可读):${path}`);
132
+ }
133
+ }
134
+ /** 把 fs 的 EACCES/EPERM 归到 denied,其余原样上抛,别把普通故障说成权限问题 */
135
+ function toLocalConfigError(error, path) {
136
+ const code = error?.code;
137
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
138
+ return new LocalConfigError("denied", path, `没有权限读写本机配置:${path}`);
139
+ }
140
+ return error instanceof Error ? error : new Error(String(error));
141
+ }