@aiti-ai/aiti-cursor-agent 0.1.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/AGENTS.md +32 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/client/src/controlled-login-client.mjs +22 -0
- package/client/src/controlled-login-dialog.mjs +270 -0
- package/cursor-agent.test.mjs +154 -0
- package/cursor-quota.test.mjs +85 -0
- package/cursor-quota.ts +138 -0
- package/dev-pack.mjs +246 -0
- package/dist/controlled-login-client.mjs +22 -0
- package/dist/controlled-login-dialog.mjs +270 -0
- package/index.ts +843 -0
- package/package.json +30 -0
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/tools/deploy.mjs +45 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# aiti-cursor-agent 扩展(部署目录直改指南)
|
|
2
|
+
|
|
3
|
+
## 结构
|
|
4
|
+
- 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
|
|
5
|
+
- `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
|
|
6
|
+
- npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
|
|
7
|
+
|
|
8
|
+
## 小改现场流程(改文案/样式/小逻辑)
|
|
9
|
+
1. 改 `src/` 下对应源码
|
|
10
|
+
2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
|
|
11
|
+
3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
|
|
12
|
+
4. 浏览器刷新页面验证
|
|
13
|
+
|
|
14
|
+
## 大改回源码仓
|
|
15
|
+
- 源码仓:`extensions/aiti-cursor-agent/`(git 管理,deploy 同步回本目录)
|
|
16
|
+
- 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-cursor-agent`)
|
|
17
|
+
|
|
18
|
+
## 数据目录(数据分层:config / cache)
|
|
19
|
+
- `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
|
|
20
|
+
- `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
|
|
21
|
+
- `ctx.dataDir` 已移除(访问即抛)——新代码禁用
|
|
22
|
+
- 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
|
|
23
|
+
|
|
24
|
+
## 红线
|
|
25
|
+
- 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
|
|
26
|
+
- 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
|
|
27
|
+
- 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
|
|
28
|
+
- 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
|
|
29
|
+
|
|
30
|
+
## 账号档 / 额度对接
|
|
31
|
+
- 本扩展已 provide `<extId>.agent` 描述符(账号档槽位)与 `accountQuota` 额度出口 `GET /login/quota`。
|
|
32
|
+
接入契约 / 现状待办 / 平台坑(.mjs reload 缓存)→ `docs/pi-web-ext-agent-account-contract.md`。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiti-ai contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @aiti-ai/aiti-cursor-agent
|
|
2
|
+
|
|
3
|
+
> 驱动 Cursor Agent CLI 会话(agentKey: cursor)——stream-json print 模式 + --resume 多轮;Cursor IDE 会话只读解析
|
|
4
|
+
|
|
5
|
+
## 来源 · Origin
|
|
6
|
+
|
|
7
|
+
本包是 **aiti** 生态的组成部分——以 [pi](https://pi.dev) 为引擎的个人 AI 开发工作台与本地调试注入生态:**aiti-agent**(自托管 Web 工作台,基于开源项目 [pi-web](https://github.com/aiti-ai/pi-web) 构建,浏览器为主操作面)+ 各开发工具内的调试注入(aiti-devtools)+ 丰富的扩展体系。
|
|
8
|
+
|
|
9
|
+
This package is part of the **aiti** ecosystem — a personal AI development workbench powered by [pi](https://pi.dev): **aiti-agent** (a self-hosted web workbench built on the open-source [pi-web](https://github.com/aiti-ai/pi-web)) plus debug injection into mainstream dev tools (aiti-devtools) and a rich extension system.
|
|
10
|
+
|
|
11
|
+
- 工作台 / workbench: <https://github.com/aiti-ai/pi-web>
|
|
12
|
+
- 生态仓库 / monorepo: <https://github.com/aiti-ai/aiti-ai>
|
|
13
|
+
- 官网 / site: <https://aiti.pw>
|
|
14
|
+
|
|
15
|
+
## 功能定位(中文)
|
|
16
|
+
|
|
17
|
+
驱动 Cursor Agent CLI 会话(agentKey: cursor)——stream-json print 模式 + --resume 多轮;Cursor IDE 会话只读解析
|
|
18
|
+
|
|
19
|
+
会话管理 · tags: `cursor` · `agent` · `sessions` · `cli`
|
|
20
|
+
|
|
21
|
+
## Positioning (English)
|
|
22
|
+
|
|
23
|
+
Drives Cursor Agent CLI sessions (agentKey: cursor) — stream-json print mode + --resume multi-turn; Cursor IDE sessions parsed read-only
|
|
24
|
+
|
|
25
|
+
## 安装 / 部署
|
|
26
|
+
|
|
27
|
+
随 aiti-agent 扩展体系部署(extman 安装 / deploy 脚本拷贝到 `~/.pi/web/extensions/`),一般不单独 npm 安装;作为依赖被引用时经 extman reconcile 自动装入共享 node_modules。
|
|
28
|
+
|
|
29
|
+
Deployed as part of the aiti-agent extension system (extman install / deploy scripts copy it to `~/.pi/web/extensions/`); usually not installed standalone.
|
|
30
|
+
|
|
31
|
+
## 文档 · Documentation
|
|
32
|
+
|
|
33
|
+
- 中文完整文档(aiti-docs): <https://aiti.pw/extensions/aiti-cursor-agent.html>
|
|
34
|
+
- English docs: <https://aiti.pw/en/extensions/aiti-cursor-agent.html>
|
|
35
|
+
- 扩展注册表 / registry: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-cursor-agent · 受控登录 client(薄壳)。
|
|
3
|
+
*
|
|
4
|
+
* 全部 UI/轮询/文案模板在 ./controlled-login-dialog.mjs
|
|
5
|
+
* (权威源 pi-web-ext/_shared/controlled-login-dialog.mjs 的部署副本,改动须双向同步)——
|
|
6
|
+
* 本文件只传扩展专属配置。
|
|
7
|
+
*/
|
|
8
|
+
import { registerControlledLoginClient } from "./controlled-login-dialog.mjs";
|
|
9
|
+
|
|
10
|
+
export default function register(ctx) {
|
|
11
|
+
registerControlledLoginClient(ctx, {
|
|
12
|
+
extId: "aiti-cursor-agent",
|
|
13
|
+
toolbarLabel: "登录 Cursor",
|
|
14
|
+
title: "登录 Cursor",
|
|
15
|
+
urlHint:
|
|
16
|
+
"后端发起官方 cursor login(无浏览器通道),把授权链接交给你——在任意设备的浏览器打开并登录 Cursor 账号授权即可;" +
|
|
17
|
+
"凭据由官方 CLI 自动落盘,本页约 2s 内自动感知。" +
|
|
18
|
+
"若本机无密钥环(headless 常见)导致登录失败,可给 pi-web 服务设置 AGENT_CREDENTIAL_STORE=file 后重试。",
|
|
19
|
+
pasteHint: null,
|
|
20
|
+
successHint: null,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* controlled-login — 外部 Agent 受控登录标准 · 前端统一弹窗(原生 h() 渲染,零构建步骤)。
|
|
3
|
+
*
|
|
4
|
+
* ⚠ 副本同步(字节级拷贝,改动须同步到全部副本;测试 `_shared/controlled-login.test.mjs` 钉住一致性)。
|
|
5
|
+
* 权威源:pi-web-ext/_shared/controlled-login-dialog.mjs
|
|
6
|
+
* 部署副本(client 侧走部署树内相对导入 —— 零构建 client 无法像 vite 客户端那样在构建期
|
|
7
|
+
* 内联 ../../_shared(资产路由拒绝越目录穿越),与 draft-keeper 的 ./core.mjs 同一惯例):
|
|
8
|
+
* pi-web-ext/aiti-{zcode,cursor,qoder,claude-code,codex}-agent/client/controlled-login-dialog.mjs
|
|
9
|
+
*
|
|
10
|
+
* 契约(docs/ext-controlled-login-standard.md §3.5):
|
|
11
|
+
* 🔗「打开授权页」按钮(target=_blank)+ URL 文本可复制 + 模式说明行
|
|
12
|
+
* + 状态区(waiting 转圈 / success ✅ 用户名 / failed / timeout / cancelled,均带重试)
|
|
13
|
+
* + B 模式(paste)专属 code 输入框+提交。
|
|
14
|
+
* 状态刷新:弹窗打开期间每 2s 轮询 GET /login/status,关闭即停
|
|
15
|
+
* (方案 §3.6 已论证并拍板的轮询例外:登录是分钟级一次性交互)。
|
|
16
|
+
*
|
|
17
|
+
* 浮动契约:面板根节点 data-ext-float="1" + data-ext-float-w/-h + data-ext-float-full="1"
|
|
18
|
+
* (aiti-float 接管拖拽/缩放/持久化/小屏全屏;未装时自身 style 居中兜底)。
|
|
19
|
+
*
|
|
20
|
+
* 接入(各扩展 client/controlled-login-client.mjs,两行):
|
|
21
|
+
* import { registerControlledLoginClient } from "./controlled-login-dialog.mjs";
|
|
22
|
+
* export default function register(ctx) { registerControlledLoginClient(ctx, { … }); }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ---------- 模块内 open 状态(toolbar 入口与 dialogs 条目同模块共享,不落 window 全局) ----------
|
|
26
|
+
let moduleSetOpen = null;
|
|
27
|
+
// 懒加载(clientImmediately:false)下 toolbar 静态条目点击时弹窗组件可能尚未挂载:
|
|
28
|
+
// 记 pending,挂载 effect 消费——避免首点失效。
|
|
29
|
+
let pendingOpen = false;
|
|
30
|
+
|
|
31
|
+
/** 打开本扩展的受控登录弹窗(toolbar 🔑 按钮 / 静态条目 meta.onClick 用)。 */
|
|
32
|
+
export function openControlledLogin() {
|
|
33
|
+
if (moduleSetOpen) moduleSetOpen(true);
|
|
34
|
+
else pendingOpen = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------- 自持样式层(幂等注入;clg- 前缀 + var(--x, 兜底),零宿主耦合) ----------
|
|
38
|
+
const STYLE_ID = "aiti-controlled-login-styles";
|
|
39
|
+
const STYLES = `
|
|
40
|
+
.clg-mask { position: fixed; inset: 0; z-index: 2147483000; background: rgba(0,0,0,.32); display: flex; align-items: center; justify-content: center; }
|
|
41
|
+
.clg-panel { width: 520px; max-width: calc(100vw - 16px); display: flex; flex-direction: column; overflow: hidden;
|
|
42
|
+
background: var(--bg,#fff); color: var(--text,#1a1a1a); border: 1px solid var(--border,#e0e0e0); border-radius: 12px;
|
|
43
|
+
box-shadow: 0 16px 64px rgba(0,0,0,.25); font-size: 13px; line-height: 1.55; }
|
|
44
|
+
.clg-head { display: flex; align-items: center; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border,#e0e0e0); flex-shrink: 0; background: var(--bg-subtle,#f7f7f7); }
|
|
45
|
+
.clg-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
|
|
46
|
+
.clg-x { margin-left: auto; border: none; background: none; cursor: pointer; color: var(--text-muted,#888); font-size: 16px; line-height: 1; padding: 4px 6px; border-radius: 6px; }
|
|
47
|
+
.clg-x:hover { background: var(--bg-hover,#eee); color: var(--text,#1a1a1a); }
|
|
48
|
+
.clg-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 10px; }
|
|
49
|
+
.clg-intro { color: var(--text-muted,#777); font-size: 12.5px; }
|
|
50
|
+
.clg-urlrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
51
|
+
.clg-open { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border: 1px solid var(--accent,#2563eb); border-radius: 8px;
|
|
52
|
+
cursor: pointer; background: var(--accent,#2563eb); color: #fff; font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap; }
|
|
53
|
+
.clg-open:hover { background: var(--accent-hover,#1d4ed8); }
|
|
54
|
+
.clg-url { flex: 1; min-width: 200px; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted,#777);
|
|
55
|
+
word-break: break-all; cursor: pointer; padding: 5px 8px; border: 1px dashed var(--border,#d8d8d8); border-radius: 6px; }
|
|
56
|
+
.clg-url:hover { border-color: var(--accent,#2563eb); color: var(--text,#1a1a1a); }
|
|
57
|
+
.clg-copied { color: #16a34a; font-size: 11.5px; }
|
|
58
|
+
.clg-status { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 8px; font-size: 12.5px; flex-wrap: wrap; }
|
|
59
|
+
.clg-status.waiting { background: rgba(37,99,235,.08); color: var(--accent,#2563eb); }
|
|
60
|
+
.clg-status.success { background: color-mix(in srgb, var(--ok, #22c55e) 12%, transparent); color: #16a34a; }
|
|
61
|
+
.clg-status.failed, .clg-status.timeout, .clg-status.cancelled { background: color-mix(in srgb, var(--danger, #ef4444) 8%, transparent); color: #dc2626; }
|
|
62
|
+
.clg-spin { width: 14px; height: 14px; flex-shrink: 0; border: 2px solid color-mix(in srgb, currentColor 25%, transparent); border-top-color: currentColor;
|
|
63
|
+
border-radius: 50%; animation: clg-rotate .9s linear infinite; }
|
|
64
|
+
@keyframes clg-rotate { to { transform: rotate(360deg); } }
|
|
65
|
+
.clg-errline { font-size: 12px; color: #dc2626; word-break: break-all; }
|
|
66
|
+
.clg-tail { font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted,#888); background: var(--bg-subtle,#f5f5f5);
|
|
67
|
+
border: 1px solid var(--border,#e0e0e0); border-radius: 8px; padding: 8px 10px; max-height: 140px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
|
|
68
|
+
.clg-coderow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
69
|
+
.clg-input { flex: 1; min-width: 180px; padding: 7px 10px; border: 1px solid var(--border,#d8d8d8); border-radius: 8px;
|
|
70
|
+
background: var(--bg,#fff); color: var(--text,#1a1a1a); font-size: 12.5px; outline: none; }
|
|
71
|
+
.clg-input:focus { border-color: var(--accent,#2563eb); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent,#2563eb) 18%, transparent); }
|
|
72
|
+
.clg-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border: 1px solid var(--border,#d8d8d8); border-radius: 7px;
|
|
73
|
+
cursor: pointer; background: var(--bg,#fff); color: var(--text,#1a1a1a); font-size: 12.5px; font-weight: 500; white-space: nowrap; }
|
|
74
|
+
.clg-btn:hover { background: var(--bg-hover,#f0f0f0); }
|
|
75
|
+
.clg-btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
76
|
+
.clg-btn.primary { background: var(--accent,#2563eb); border-color: var(--accent,#2563eb); color: #fff; }
|
|
77
|
+
.clg-btn.primary:hover { background: var(--accent-hover,#1d4ed8); }
|
|
78
|
+
.clg-foot { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
|
79
|
+
.clg-hint { color: var(--text-dim,#999); font-size: 11.5px; }
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
let stylesInjected = false;
|
|
83
|
+
function ensureStyles() {
|
|
84
|
+
if (stylesInjected) return;
|
|
85
|
+
if (typeof document === "undefined") return;
|
|
86
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
87
|
+
const el = document.createElement("style");
|
|
88
|
+
el.id = STYLE_ID;
|
|
89
|
+
el.textContent = STYLES;
|
|
90
|
+
document.head.appendChild(el);
|
|
91
|
+
}
|
|
92
|
+
stylesInjected = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 统一弹窗注册(每扩展 client 的薄壳调用一次)。
|
|
97
|
+
*
|
|
98
|
+
* @param {*} ctx 宿主 client 上下文(h/useState/useEffect/apiUrl/registerItems)
|
|
99
|
+
* @param {object} config
|
|
100
|
+
* @param {string} config.extId 扩展 id(API 前缀 / toolbar item id 用)
|
|
101
|
+
* @param {string} config.toolbarLabel 工具条按钮 title/label(如 "登录 ZCode")
|
|
102
|
+
* @param {string} config.title 弹窗标题
|
|
103
|
+
* @param {string} [config.urlHint] A url-only 模式说明行
|
|
104
|
+
* @param {string} [config.pasteHint] B paste-relay 模式说明行
|
|
105
|
+
* @param {string} [config.successHint] 成功后的补充说明(如凭据换 key 提示)
|
|
106
|
+
*/
|
|
107
|
+
export function registerControlledLoginClient(ctx, config) {
|
|
108
|
+
ensureStyles();
|
|
109
|
+
const h = (...a) => ctx.h(...a);
|
|
110
|
+
const useState = (v) => ctx.useState(v);
|
|
111
|
+
const useEffect = (fn, deps) => ctx.useEffect(fn, deps);
|
|
112
|
+
const api = (p, init) => fetch(ctx.apiUrl("/api/extensions/" + config.extId + p), { cache: "no-store", ...init });
|
|
113
|
+
const jsonInit = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
114
|
+
|
|
115
|
+
// ---- 工具条入口:声明式图标按钮(meta.icon + meta.onClick、无 render —— 宿主统一渲染 36×36 线图按钮,禁 emoji) ----
|
|
116
|
+
ctx.registerItems("toolbar", [{
|
|
117
|
+
id: `${config.extId}-controlled-login`,
|
|
118
|
+
meta: {
|
|
119
|
+
label: config.toolbarLabel,
|
|
120
|
+
title: config.toolbarLabel,
|
|
121
|
+
icon: "icons/key-round.svg",
|
|
122
|
+
onClick: () => openControlledLogin(),
|
|
123
|
+
},
|
|
124
|
+
}]);
|
|
125
|
+
// ---- 弹窗条目(dialogs slot;宿主包 display:contents 壳,float 持久化键认它) ----
|
|
126
|
+
ctx.registerItems("dialogs", [{
|
|
127
|
+
id: `${config.extId}-controlled-login-dialog`,
|
|
128
|
+
render: () => h(ControlledLoginDialog),
|
|
129
|
+
}]);
|
|
130
|
+
|
|
131
|
+
function ControlledLoginDialog() {
|
|
132
|
+
const [open, setOpen] = useState(false);
|
|
133
|
+
const [status, setStatus] = useState(null); // /login/status 响应({state,...})
|
|
134
|
+
const [busy, setBusy] = useState(false);
|
|
135
|
+
const [reqError, setReqError] = useState(null); // 请求层错误(网络/400)
|
|
136
|
+
const [code, setCode] = useState("");
|
|
137
|
+
const [copied, setCopied] = useState(false);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
moduleSetOpen = setOpen;
|
|
141
|
+
if (pendingOpen) { pendingOpen = false; setOpen(true); }
|
|
142
|
+
return () => { if (moduleSetOpen === setOpen) moduleSetOpen = null; };
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
// 打开期间 2s 轮询 /login/status(§3.6 拍板例外);关闭即停
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!open) return;
|
|
148
|
+
let stopped = false;
|
|
149
|
+
let timer = null;
|
|
150
|
+
const tick = async () => {
|
|
151
|
+
try {
|
|
152
|
+
const r = await api("/login/status");
|
|
153
|
+
const d = await r.json();
|
|
154
|
+
if (!stopped) { setStatus(d); setReqError(null); }
|
|
155
|
+
} catch { /* 网络抖动忽略,下轮再试 */ }
|
|
156
|
+
if (!stopped) timer = setTimeout(tick, 2000);
|
|
157
|
+
};
|
|
158
|
+
tick();
|
|
159
|
+
return () => { stopped = true; if (timer) clearTimeout(timer); };
|
|
160
|
+
}, [open]);
|
|
161
|
+
|
|
162
|
+
if (!open) return null;
|
|
163
|
+
|
|
164
|
+
const st = status?.state ?? "idle";
|
|
165
|
+
const mode = status?.mode ?? "url";
|
|
166
|
+
const pasteMode = mode === "paste";
|
|
167
|
+
|
|
168
|
+
async function call(p, init) {
|
|
169
|
+
setBusy(true); setReqError(null);
|
|
170
|
+
try {
|
|
171
|
+
const r = await api(p, init);
|
|
172
|
+
const d = await r.json();
|
|
173
|
+
if (d && d.error) setReqError(d.error);
|
|
174
|
+
if (d) setStatus(d);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
setReqError(e?.message || String(e));
|
|
177
|
+
}
|
|
178
|
+
setBusy(false);
|
|
179
|
+
}
|
|
180
|
+
const startLogin = () => call("/login/start", jsonInit({}));
|
|
181
|
+
const cancelLogin = () => call("/login/cancel", jsonInit({}));
|
|
182
|
+
const submitCode = () => { if (code.trim()) call("/login/submit", jsonInit({ code })); };
|
|
183
|
+
|
|
184
|
+
async function copyUrl() {
|
|
185
|
+
const url = status?.url || "";
|
|
186
|
+
if (!url) return;
|
|
187
|
+
try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 剪贴板不可用:URL 文本本身可选中复制 */ }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- 状态区 ----
|
|
191
|
+
let statusLine = null;
|
|
192
|
+
if (st === "idle") {
|
|
193
|
+
statusLine = h("div", { className: "clg-status waiting" }, "尚未发起登录");
|
|
194
|
+
} else if (st === "waiting") {
|
|
195
|
+
statusLine = h("div", { className: "clg-status waiting" },
|
|
196
|
+
h("span", { className: "clg-spin" }),
|
|
197
|
+
status?.url ? "等待你在浏览器完成授权…(本页自动感知,约 2s 内)" : "正在启动官方登录进程,等待授权链接…",
|
|
198
|
+
h("button", { className: "clg-btn", disabled: busy, style: { marginLeft: "auto" }, onClick: cancelLogin }, "取消"),
|
|
199
|
+
);
|
|
200
|
+
} else if (st === "success") {
|
|
201
|
+
statusLine = h("div", { className: "clg-status success" },
|
|
202
|
+
"登录成功", status?.user ? h("span", { style: { fontWeight: 700 } }, status.user) : null,
|
|
203
|
+
config.successHint ? h("span", { className: "clg-hint", style: { color: "inherit" } }, config.successHint) : null,
|
|
204
|
+
);
|
|
205
|
+
} else if (st === "failed" || st === "timeout" || st === "cancelled") {
|
|
206
|
+
const label = st === "failed" ? "登录失败" : st === "timeout" ? "登录超时" : "已取消";
|
|
207
|
+
statusLine = h("div", { className: "clg-status " + st },
|
|
208
|
+
h("span", null, (st === "timeout" ? "⏱ " : st === "cancelled" ? "⚪ " : "❌ ") + label),
|
|
209
|
+
status?.error ? h("span", { className: "clg-errline" }, status.error) : null,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return h("div", { className: "clg-mask", onClick: () => setOpen(false) },
|
|
214
|
+
h("div", {
|
|
215
|
+
className: "clg-panel",
|
|
216
|
+
"data-ext-float": "1", "data-ext-float-w": 520, "data-ext-float-h": 380, "data-ext-float-full": "1",
|
|
217
|
+
onClick: (e) => e.stopPropagation(),
|
|
218
|
+
},
|
|
219
|
+
// ── 标题栏(首个子元素 = float 拖拽把手)──
|
|
220
|
+
h("div", { className: "clg-head" },
|
|
221
|
+
h("span", { className: "clg-title" }, "🔑 " + config.title),
|
|
222
|
+
h("button", { className: "clg-x", title: "关闭", onClick: () => setOpen(false) }, "✕"),
|
|
223
|
+
),
|
|
224
|
+
h("div", { className: "clg-body" },
|
|
225
|
+
config.urlHint && !pasteMode ? h("div", { className: "clg-intro" }, config.urlHint) : null,
|
|
226
|
+
pasteMode && config.pasteHint ? h("div", { className: "clg-intro" }, config.pasteHint) : null,
|
|
227
|
+
|
|
228
|
+
// ── URL 区(waiting 且已抓到链接)──
|
|
229
|
+
st === "waiting" && status?.url
|
|
230
|
+
? h("div", { className: "clg-urlrow" },
|
|
231
|
+
h("a", { className: "clg-open", href: status.url, target: "_blank", rel: "noopener noreferrer" }, "打开授权页"),
|
|
232
|
+
h("span", { className: "clg-url", title: "点击复制链接", onClick: copyUrl }, status.url),
|
|
233
|
+
copied ? h("span", { className: "clg-copied" }, "已复制") : null,
|
|
234
|
+
)
|
|
235
|
+
: null,
|
|
236
|
+
|
|
237
|
+
// ── B 模式 code 输入(paste 且 waiting)──
|
|
238
|
+
pasteMode && st === "waiting"
|
|
239
|
+
? h("div", { className: "clg-coderow" },
|
|
240
|
+
h("input", {
|
|
241
|
+
className: "clg-input",
|
|
242
|
+
placeholder: "粘贴授权完成页显示的 code(或完整回调链接)",
|
|
243
|
+
value: code,
|
|
244
|
+
onChange: (e) => setCode(e.target.value),
|
|
245
|
+
onKeyDown: (e) => { if (e.key === "Enter") submitCode(); },
|
|
246
|
+
}),
|
|
247
|
+
h("button", { className: "clg-btn primary", disabled: busy || !code.trim(), onClick: submitCode }, busy ? "…" : "提交 code"),
|
|
248
|
+
)
|
|
249
|
+
: null,
|
|
250
|
+
|
|
251
|
+
statusLine,
|
|
252
|
+
reqError ? h("div", { className: "clg-errline" }, reqError) : null,
|
|
253
|
+
|
|
254
|
+
// CLI 升级改输出格式导致 URL 抓取失败时:原文透出(人工可读可复制,方案 §7)
|
|
255
|
+
(st === "failed" || st === "timeout") && status?.outputTail
|
|
256
|
+
? h("div", { className: "clg-tail" }, status.outputTail)
|
|
257
|
+
: null,
|
|
258
|
+
|
|
259
|
+
// ── 底部动作:idle/终态带(重)试 ──
|
|
260
|
+
h("div", { className: "clg-foot" },
|
|
261
|
+
st === "idle" || st === "failed" || st === "timeout" || st === "cancelled" || st === "success"
|
|
262
|
+
? h("button", { className: "clg-btn primary", disabled: busy, onClick: startLogin }, busy ? "…" : st === "idle" ? "发起登录" : "重试")
|
|
263
|
+
: null,
|
|
264
|
+
h("span", { className: "clg-hint" }, "授权在浏览器完成,凭据由官方 CLI 落盘;本页关闭不影响进行中的登录。"),
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-cursor-agent — 单测(D6):帧状态机 + 登录 driver 契约。
|
|
3
|
+
*
|
|
4
|
+
* cursor 协议核心(cursor-cli-store wire 解码)由 _shared/cursor-cli-store.test.mjs
|
|
5
|
+
* 钉死;本文件补扩展自身两块:
|
|
6
|
+
* 1. handle 帧状态机(经 mock startup → createSession 拿真 handle,私有 onLine 直驱):
|
|
7
|
+
* assistant 文本累积、result 收口(settled + running 复位)、thinking 忽略、
|
|
8
|
+
* is_error → settled error、turnEnded 后晚到帧忽略(防重复 settled);
|
|
9
|
+
* 2. 受控登录 driver(extractUrl/interpret/bin 名)。
|
|
10
|
+
*
|
|
11
|
+
* Run: node --experimental-strip-types --test pi-web-ext/aiti-cursor-agent/cursor-agent.test.mjs
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
|
|
16
|
+
const mod = await import("./index.ts");
|
|
17
|
+
const { extractCursorLoginUrl, interpretCursorLogin, cursorLoginDriver } = mod;
|
|
18
|
+
|
|
19
|
+
/** mock startup → 捕获 createSession → 造 handle(不 spawn:spawn 在 prompt 里)。 */
|
|
20
|
+
async function makeHandle() {
|
|
21
|
+
let created = null;
|
|
22
|
+
const providers = { register(p) { created = p; return "aiti-cursor-agent/cursor"; } };
|
|
23
|
+
const ctx = { sessionProviders: providers, configDir: "/tmp" };
|
|
24
|
+
await mod.default.server.startup(ctx);
|
|
25
|
+
assert.ok(created, "startup 应注册 provider");
|
|
26
|
+
const h = await created.createSession({ cwd: "/tmp" });
|
|
27
|
+
const events = [];
|
|
28
|
+
h.subscribe((e) => events.push(e));
|
|
29
|
+
const write = (frame) => h.consumeLine(JSON.stringify(frame));
|
|
30
|
+
return { h, events, write };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("帧状态机:assistant 累积 → result 收口(settled + running 复位 + 历史入库)", async () => {
|
|
34
|
+
const { h, events, write } = await makeHandle();
|
|
35
|
+
h.running = true; // 模拟 prompt 后在途
|
|
36
|
+
write({ type: "assistant", message: { model: "composer-1", content: [{ type: "text", text: "你好" }] } });
|
|
37
|
+
write({ type: "assistant", message: { content: [{ type: "text", text: "!" }] } });
|
|
38
|
+
write({ type: "result", result: "你好!", is_error: false });
|
|
39
|
+
const settled = events.find((e) => e.type === "agent_settled");
|
|
40
|
+
assert.ok(settled, "result 应发 settled");
|
|
41
|
+
assert.equal(settled.result, "你好!");
|
|
42
|
+
assert.equal(h.isRunning(), false, "result 即收口(不等进程 exit)");
|
|
43
|
+
const last = h.history.at(-1);
|
|
44
|
+
assert.equal(last?.role, "assistant");
|
|
45
|
+
assert.equal(last?.content?.[0]?.text, "你好!");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("帧状态机:thinking 忽略;is_error → settled error", async () => {
|
|
49
|
+
const { h, events, write } = await makeHandle();
|
|
50
|
+
h.running = true;
|
|
51
|
+
write({ type: "thinking", text: "内部推理" });
|
|
52
|
+
assert.ok(!events.some((e) => e.type === "message_update"), "thinking 不进正文流");
|
|
53
|
+
write({ type: "result", result: "", is_error: true });
|
|
54
|
+
const settled = events.find((e) => e.type === "agent_settled");
|
|
55
|
+
assert.ok(settled?.error, "is_error 应带 error");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("帧状态机:turnEnded 后晚到帧忽略(防重复 settled)", async () => {
|
|
59
|
+
const { h, events, write } = await makeHandle();
|
|
60
|
+
h.running = true;
|
|
61
|
+
write({ type: "result", result: "一" });
|
|
62
|
+
const settledCount = () => events.filter((e) => e.type === "agent_settled").length;
|
|
63
|
+
assert.equal(settledCount(), 1);
|
|
64
|
+
write({ type: "assistant", message: { content: [{ type: "text", text: "晚到" }] } });
|
|
65
|
+
write({ type: "result", result: "晚到的二" });
|
|
66
|
+
assert.equal(settledCount(), 1, "重复 result/晚到帧不得二次收口");
|
|
67
|
+
assert.equal(h.history.at(-1)?.content?.[0]?.text, "一");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("登录 driver:URL 提取 / 结果判定 / bin 名", () => {
|
|
71
|
+
assert.equal(
|
|
72
|
+
extractCursorLoginUrl("navigate to this link: https://cursor.com/login-device?code=XYZ to finish"),
|
|
73
|
+
"https://cursor.com/login-device?code=XYZ",
|
|
74
|
+
);
|
|
75
|
+
assert.equal(extractCursorLoginUrl("no url here"), null);
|
|
76
|
+
assert.deepEqual(interpretCursorLogin(0, "", ""), { state: "success" });
|
|
77
|
+
const failed = interpretCursorLogin(1, "out", "err line");
|
|
78
|
+
assert.equal(failed.state, "failed");
|
|
79
|
+
assert.equal(failed.error, "err line");
|
|
80
|
+
assert.equal(cursorLoginDriver.agentId, "cursor");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---- 队列接线(共享层 backend-queue,docs/aiti-agent-queue-clear-recall-audit-plan.md §2)----
|
|
84
|
+
// 不真 spawn:settle 时队列已清空 → scheduleDrain no-op,dispatch 不会被触发。
|
|
85
|
+
|
|
86
|
+
test("队列接线:运行中 followUp 入队 + queue_update;list/move/remove 转发共享层", async () => {
|
|
87
|
+
const { h, events } = await makeHandle();
|
|
88
|
+
h.running = true;
|
|
89
|
+
await h.followUp("a");
|
|
90
|
+
await h.followUp("b");
|
|
91
|
+
const updates = events.filter((e) => e.type === "queue_update");
|
|
92
|
+
assert.ok(updates.length >= 2, "每次入队应广播 queue_update");
|
|
93
|
+
assert.deepEqual(updates.at(-1).followUp, ["a", "b"]);
|
|
94
|
+
const list = await h.queueManage({ action: "list" });
|
|
95
|
+
assert.deepEqual(list.followUp, ["a", "b"]);
|
|
96
|
+
assert.deepEqual(list.steering, []);
|
|
97
|
+
await h.queueManage({ action: "move", from: 0, to: 1 });
|
|
98
|
+
assert.deepEqual((await h.queueManage({ action: "list" })).followUp, ["b", "a"]);
|
|
99
|
+
const rm = await h.queueManage({ action: "remove", index: 0 });
|
|
100
|
+
assert.equal(rm.entry.text, "b");
|
|
101
|
+
assert.equal(rm.removed.text, "b");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("队列接线:clear 返回清空前文本(pi clearQueue 契约——前端「全部编辑」回填输入框)", async () => {
|
|
105
|
+
const { h, events, write } = await makeHandle();
|
|
106
|
+
h.running = true;
|
|
107
|
+
await h.followUp("msg-1");
|
|
108
|
+
await h.followUp("msg-2");
|
|
109
|
+
const cleared = await h.queueManage({ action: "clear" });
|
|
110
|
+
assert.deepEqual(cleared.followUp, ["msg-1", "msg-2"], "clear 须带回清空前的文本");
|
|
111
|
+
assert.deepEqual((await h.queueManage({ action: "list" })).followUp, []);
|
|
112
|
+
assert.ok(events.some((e) => e.type === "queue_update" && e.followUp.length === 0), "清空应广播空 queue_update");
|
|
113
|
+
// settle(队列已空 → scheduleDrain no-op,不会真 spawn)
|
|
114
|
+
write({ type: "result", result: "ok" });
|
|
115
|
+
assert.ok(events.some((e) => e.type === "agent_settled"));
|
|
116
|
+
assert.deepEqual((await h.queueManage({ action: "list" })).followUp, [], "settle 后队列不得复活");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("队列接线:recall 单条取回;steer 组操作给人话错误", async () => {
|
|
120
|
+
const { h } = await makeHandle();
|
|
121
|
+
h.running = true;
|
|
122
|
+
await h.followUp("x");
|
|
123
|
+
const rec = await h.queueManage({ action: "recall", index: 0 });
|
|
124
|
+
assert.equal(rec.entry.text, "x");
|
|
125
|
+
await assert.rejects(() => h.queueManage({ action: "clear", kind: "steer" }), /不支持/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---- 上下文用量(机会式;docs/pi-web-external-agent-context-usage-plan.md)----
|
|
129
|
+
|
|
130
|
+
test("contextUsage 机会式:result.usage(claude 形态)→ getContextUsage;composer 模型窗口未知 → undefined", async () => {
|
|
131
|
+
const { h, write } = await makeHandle();
|
|
132
|
+
h.running = true;
|
|
133
|
+
write({ type: "assistant", message: { model: "claude-sonnet-4-5", content: [{ type: "text", text: "hi" }] } });
|
|
134
|
+
write({ type: "result", result: "hi", is_error: false,
|
|
135
|
+
usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 500, cache_creation_input_tokens: 10 } });
|
|
136
|
+
const cu = h.getContextUsage();
|
|
137
|
+
assert.ok(cu, "usage 齐全 + 模型可判 → 有数据");
|
|
138
|
+
assert.equal(cu.tokens, 610);
|
|
139
|
+
assert.equal(cu.contextWindow, 200_000);
|
|
140
|
+
const totals = h.getUsageTotals();
|
|
141
|
+
assert.equal(totals.inputTokens, 100);
|
|
142
|
+
assert.equal(totals.turns, 1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("contextUsage 机会式:无 usage 帧 → getContextUsage undefined(不编数据),totals 全 0", async () => {
|
|
146
|
+
const { h, write } = await makeHandle();
|
|
147
|
+
h.running = true;
|
|
148
|
+
write({ type: "assistant", message: { model: "composer-1", content: [{ type: "text", text: "hi" }] } });
|
|
149
|
+
write({ type: "result", result: "hi", is_error: false });
|
|
150
|
+
assert.equal(h.getContextUsage(), undefined, "composer 窗口未知 + 无 usage → 空轨");
|
|
151
|
+
const totals = h.getUsageTotals();
|
|
152
|
+
assert.equal(totals.turns, 0);
|
|
153
|
+
assert.equal(totals.inputTokens, 0);
|
|
154
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cursor-quota 单测(归一化 + 凭据读取 + fetch 注入;响应样本来自 2026-09-13 真机实测)。
|
|
3
|
+
*
|
|
4
|
+
* Run: node --test pi-web-ext/aiti-cursor-agent/cursor-quota.test.mjs
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const { normalizeCursorUsage, fetchCursorQuota, readCursorToken, cursorAuthPath } = await import("./cursor-quota.ts");
|
|
13
|
+
|
|
14
|
+
/** 真机实测样本(GetCurrentPeriodUsage)。 */
|
|
15
|
+
const SAMPLE = {
|
|
16
|
+
billingCycleStart: "1787582660974",
|
|
17
|
+
billingCycleEnd: "1790261060974",
|
|
18
|
+
planUsage: { totalSpend: 46, bonusSpend: 46, remainingBonus: false, autoPercentUsed: 46, apiPercentUsed: 0, totalPercentUsed: 23 },
|
|
19
|
+
spendLimitUsage: { pooledLimit: 0, pooledRemaining: 0, individualLimit: 0, limitType: "user", overallLimit: 0, overallRemaining: 0 },
|
|
20
|
+
displayThreshold: 200,
|
|
21
|
+
displayMessage: "You've used 0% of your included usage",
|
|
22
|
+
autoModelSelectedDisplayMessage: "You've used 23% of your included total usage",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
test("normalizeCursorUsage:实测样本 → 剩余 77% + auto/api 分包", () => {
|
|
26
|
+
const v = normalizeCursorUsage(SAMPLE, 1_700_000_000_000);
|
|
27
|
+
assert.equal(v.available, true);
|
|
28
|
+
assert.equal(v.unit, "%");
|
|
29
|
+
assert.equal(v.usedPercent, 23);
|
|
30
|
+
assert.equal(v.remaining, 77);
|
|
31
|
+
assert.equal(v.total, 100);
|
|
32
|
+
assert.equal(v.display, "额度 剩余 77%");
|
|
33
|
+
assert.deepEqual(v.packages.map((p) => [p.name, p.usedPercent]), [["自动模型(auto)", 46], ["API 模型", 0], ["总用量", 23]]);
|
|
34
|
+
assert.equal(v.expiresAt, 1790261060974);
|
|
35
|
+
assert.equal(v.note, "You've used 0% of your included usage");
|
|
36
|
+
assert.equal(v.fetchedAt, 1_700_000_000_000);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("normalizeCursorUsage:消费上限非 0 → 追加一行;缺 totalPercentUsed → unparsed", () => {
|
|
40
|
+
const v = normalizeCursorUsage({ planUsage: { totalPercentUsed: 10, autoPercentUsed: 20, apiPercentUsed: 1 }, spendLimitUsage: { overallLimit: 500, overallRemaining: 120 } });
|
|
41
|
+
assert.equal(v.available, true);
|
|
42
|
+
assert.equal(v.packages.at(-1).name, "消费上限");
|
|
43
|
+
assert.match(v.packages.at(-1).note, /上限 500 \/ 剩余 120/);
|
|
44
|
+
|
|
45
|
+
const bad = normalizeCursorUsage({ planUsage: {} });
|
|
46
|
+
assert.equal(bad.available, false);
|
|
47
|
+
assert.equal(bad.reason, "unparsed");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("fetchCursorQuota:成功(fetchImpl 注入)+ 非 200 抛错", async () => {
|
|
51
|
+
const ok = await fetchCursorQuota({
|
|
52
|
+
token: "tok",
|
|
53
|
+
fetchImpl: async (url, init) => {
|
|
54
|
+
assert.match(String(url), /GetCurrentPeriodUsage$/);
|
|
55
|
+
assert.equal(init.method, "POST");
|
|
56
|
+
assert.equal(init.headers.Authorization, "Bearer tok");
|
|
57
|
+
assert.equal(init.headers["Connect-Protocol-Version"], "1");
|
|
58
|
+
return { ok: true, status: 200, json: async () => SAMPLE };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
assert.equal(ok.available, true);
|
|
62
|
+
assert.equal(ok.remaining, 77);
|
|
63
|
+
|
|
64
|
+
await assert.rejects(
|
|
65
|
+
fetchCursorQuota({ token: "t", fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({}) }) }),
|
|
66
|
+
/HTTP 401/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("readCursorToken:primary/legacy 路径优先 + 坏文件兜底", () => {
|
|
71
|
+
const home = mkdtempSync(join(tmpdir(), "cursor-quota-"));
|
|
72
|
+
try {
|
|
73
|
+
assert.equal(cursorAuthPath(home), null, "无凭据时 null");
|
|
74
|
+
mkdirSync(join(home, ".cursor"), { recursive: true });
|
|
75
|
+
writeFileSync(join(home, ".cursor", "auth.json"), JSON.stringify({ accessToken: "legacy-tok" }));
|
|
76
|
+
assert.equal(readCursorToken(home), "legacy-tok", "旧路径兜底");
|
|
77
|
+
mkdirSync(join(home, ".config", "cursor"), { recursive: true });
|
|
78
|
+
writeFileSync(join(home, ".config", "cursor", "auth.json"), JSON.stringify({ accessToken: "primary-tok" }));
|
|
79
|
+
assert.equal(readCursorToken(home), "primary-tok", "primary 优先");
|
|
80
|
+
writeFileSync(join(home, ".config", "cursor", "auth.json"), "{broken");
|
|
81
|
+
assert.equal(readCursorToken(home), null, "坏文件 → null");
|
|
82
|
+
} finally {
|
|
83
|
+
rmSync(home, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|