@super-coding/cli 0.1.0 → 0.2.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/cli.js +22 -0
- package/dist/daemon/index.js +13 -1
- package/dist/daemon/orchestrator.js +40 -4
- package/dist/pingcode/builtin/SKILL.md +47 -0
- package/dist/pingcode/builtin/reference.md +123 -0
- package/dist/pingcode/cli.js +138 -0
- package/dist/pingcode/config.js +106 -0
- package/dist/pingcode/detect.js +73 -0
- package/dist/pingcode/index.js +8 -0
- package/dist/pingcode/launcher.js +60 -0
- package/dist/pingcode/runtime.js +167 -0
- package/dist/pingcode/skill.js +51 -0
- package/dist/pingcode/token.js +81 -0
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { logsDaemon, restartDaemon, runDaemonForeground, startDaemon, statusDaemon, stopDaemon, } from "./daemon/manage.js";
|
|
5
|
+
import { connectPingcode, disconnectPingcode, printPingcodeStatus } from "./pingcode/index.js";
|
|
5
6
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
6
7
|
const program = new Command();
|
|
7
8
|
program.name("scode").version(pkg.version).description("super-coding CLI");
|
|
@@ -32,4 +33,25 @@ daemon
|
|
|
32
33
|
daemon.command("run").description("前台运行(调试/外部 supervisor 托管)").action(() => run(() => runDaemonForeground()));
|
|
33
34
|
// 嵌套命令默认无 help 子命令;补上使 `daemon help` 可用(npm 会吞掉 -h,`help` 是 npm 安全路径)
|
|
34
35
|
daemon.addHelpCommand("help [command]");
|
|
36
|
+
const pingcode = program
|
|
37
|
+
.command("pingcode")
|
|
38
|
+
.description("PingCode(pc)集成:保存本机凭据、查看可用性");
|
|
39
|
+
pingcode
|
|
40
|
+
.command("status")
|
|
41
|
+
.description("查看 pc 二进制、凭据来源与凭据注入方式")
|
|
42
|
+
.action(() => run(() => printPingcodeStatus()));
|
|
43
|
+
pingcode
|
|
44
|
+
.command("connect")
|
|
45
|
+
.description("保存 PingCode 凭据(写入 ~/.super-coding/pingcode.json,权限 0600)")
|
|
46
|
+
.option("--token <token>", "用户令牌(以个人身份调用)")
|
|
47
|
+
.option("--client-id <id>", "应用 Client ID(与 client-secret 成对)")
|
|
48
|
+
.option("--client-secret <secret>", "应用 Client Secret")
|
|
49
|
+
.option("--base-url <url>", "Open API 地址(默认 https://open.pingcode.com;私有部署填 https://<host>/open)")
|
|
50
|
+
.option("--no-verify", "跳过 client 凭据的令牌换取校验")
|
|
51
|
+
.action((opts) => run(() => connectPingcode(opts)));
|
|
52
|
+
pingcode
|
|
53
|
+
.command("disconnect")
|
|
54
|
+
.description("删除本地 PingCode 凭据")
|
|
55
|
+
.action(() => run(() => disconnectPingcode()));
|
|
56
|
+
pingcode.addHelpCommand("help [command]");
|
|
35
57
|
await program.parseAsync();
|
package/dist/daemon/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { detectRunnerFeatures } from "./adapters/features.js";
|
|
|
5
5
|
import { adapters, agentBinaries } from "./adapters/registry.js";
|
|
6
6
|
import { loadDaemonConfig, loadDevice, removeDevice, restoreDeviceBackup } from "./device-store.js";
|
|
7
7
|
import { logFile } from "./paths.js";
|
|
8
|
+
import { createPingcodeBridge } from "../pingcode/index.js";
|
|
8
9
|
import { Orchestrator } from "./orchestrator.js";
|
|
9
10
|
import { runPairing, PairingTimeoutError } from "./pairing.js";
|
|
10
11
|
import { RpcException, RpcRouter } from "./rpc.js";
|
|
@@ -36,6 +37,13 @@ async function detectAgents() {
|
|
|
36
37
|
const results = await Promise.all(probes);
|
|
37
38
|
return results.filter((r) => r !== null);
|
|
38
39
|
}
|
|
40
|
+
/** 一行日志描述;不可用时只报原因,修复步骤留给 `scode pingcode status` */
|
|
41
|
+
function describePingcode(status) {
|
|
42
|
+
if (!status.available) {
|
|
43
|
+
return `不可用(${status.reason ?? "unknown"}),运行 scode pingcode status 查看修复建议`;
|
|
44
|
+
}
|
|
45
|
+
return `可用(${status.binary?.version ?? "pc"} · ${status.credential ?? "-"} · ${status.injection})`;
|
|
46
|
+
}
|
|
39
47
|
export async function runDaemon() {
|
|
40
48
|
const logBuffer = new LogBuffer();
|
|
41
49
|
logBuffer.instrumentConsole(logFile()); // 前台模式同时落盘 daemon.log(后台由 stdio 重定向承载)
|
|
@@ -183,7 +191,11 @@ export async function runDaemon() {
|
|
|
183
191
|
});
|
|
184
192
|
// orch 与 ws 相互依赖:orch 用转发 sender,连接建立后消息才会真正发出
|
|
185
193
|
let wsRef;
|
|
186
|
-
|
|
194
|
+
// PingCode(pc)集成:探测本机二进制与凭据。不可用只记一行日志,不阻塞启动——
|
|
195
|
+
// 配置可能在运行期才写入(scode pingcode connect),bridge 每轮任务会自行重探。
|
|
196
|
+
const pingcode = await createPingcodeBridge();
|
|
197
|
+
console.log(`[daemon] pingcode: ${describePingcode(pingcode.status())}`);
|
|
198
|
+
const orch = new Orchestrator({ send: (m) => wsRef.send(m) }, store, adapters, router, daemonConfig.allowedCwds, (runner) => detectRunnerFeatures(runner, agentBinaries[runner] ?? runner), pingcode);
|
|
187
199
|
orchRef = orch;
|
|
188
200
|
// 4401 双确认:平台瞬时空读也可能让 auth 回源报「查无此设备」——首次只告警并
|
|
189
201
|
// 自动退避重连(保留凭证),连续第二次才认定确定性解绑(凭证删前备份见 removeDevice)。
|
|
@@ -4,6 +4,15 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { AgentError, prepareAgent, restoreAgentLaunch, validateBusinessAgent } from "./agent/index.js";
|
|
6
6
|
import { promptWithSkills, syncRunSkills } from "./run-skills.js";
|
|
7
|
+
/** 合并多份子进程环境变量;后者覆盖前者(PingCode 注入的 PC_* 必须压过用户配置) */
|
|
8
|
+
function mergeEnv(...parts) {
|
|
9
|
+
const merged = {};
|
|
10
|
+
for (const part of parts) {
|
|
11
|
+
if (part)
|
|
12
|
+
Object.assign(merged, part);
|
|
13
|
+
}
|
|
14
|
+
return Object.keys(merged).length ? merged : undefined;
|
|
15
|
+
}
|
|
7
16
|
/** 会话目录默认基底(持久位置:目录保留不删,不用 /tmp) */
|
|
8
17
|
const DEFAULT_WORKDIR_BASE = join(homedir(), ".super-coding", "workspaces");
|
|
9
18
|
/**
|
|
@@ -17,6 +26,7 @@ export class Orchestrator {
|
|
|
17
26
|
rpc;
|
|
18
27
|
allowedCwds;
|
|
19
28
|
featuresFor;
|
|
29
|
+
pingcode;
|
|
20
30
|
sessions = new Map();
|
|
21
31
|
/** 本连接已经请求过水位的运行,避免同一批在确认前被反复重放 */
|
|
22
32
|
syncRequested = new Set();
|
|
@@ -25,13 +35,16 @@ export class Orchestrator {
|
|
|
25
35
|
/** run:start 的 cwd 白名单(前缀匹配);缺省不限制 */
|
|
26
36
|
allowedCwds,
|
|
27
37
|
/** runner 能力探测(默认按不支持 --agent 处理,测试可注入) */
|
|
28
|
-
featuresFor
|
|
38
|
+
featuresFor,
|
|
39
|
+
/** PingCode(pc)集成;不可用/未启用时整条链路跳过(不影响会话本身) */
|
|
40
|
+
pingcode) {
|
|
29
41
|
this.ws = ws;
|
|
30
42
|
this.store = store;
|
|
31
43
|
this.adapters = adapters;
|
|
32
44
|
this.rpc = rpc;
|
|
33
45
|
this.allowedCwds = allowedCwds;
|
|
34
46
|
this.featuresFor = featuresFor;
|
|
47
|
+
this.pingcode = pingcode;
|
|
35
48
|
}
|
|
36
49
|
activeRuns() {
|
|
37
50
|
return [...this.sessions.keys()];
|
|
@@ -175,9 +188,21 @@ export class Orchestrator {
|
|
|
175
188
|
}
|
|
176
189
|
}
|
|
177
190
|
}
|
|
191
|
+
// PingCode(pc)集成:令牌换取失败等一律降级为「本轮不注入」,绝不能因此拒绝会话
|
|
192
|
+
let pingcodeRun = null;
|
|
193
|
+
if (this.pingcode) {
|
|
194
|
+
try {
|
|
195
|
+
pingcodeRun = await this.pingcode.prepareRun(workdir);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.error(`[daemon] PingCode 注入准备失败,本轮不注入 pc:${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
178
201
|
let visible;
|
|
179
202
|
try {
|
|
180
|
-
|
|
203
|
+
// 平台下发的 skill 在前,内置 pingcode skill 在后:同名时平台版占用 slug,内置的会拿到 -2
|
|
204
|
+
const skills = pingcodeRun ? [...(msg.skills ?? []), pingcodeRun.skill] : (msg.skills ?? []);
|
|
205
|
+
visible = await syncRunSkills(workdir, msg.agent, skills);
|
|
181
206
|
}
|
|
182
207
|
catch (err) {
|
|
183
208
|
this.reject(msg.run_id, `skill materialize failed: ${err.message}`, "skill_materialize_failed");
|
|
@@ -227,11 +252,12 @@ export class Orchestrator {
|
|
|
227
252
|
this.ws.send({ type: "run:ack", run_id: msg.run_id, status: "accepted", cwd: workdir });
|
|
228
253
|
let session;
|
|
229
254
|
try {
|
|
255
|
+
const env = mergeEnv(prepared?.env, pingcodeRun?.env);
|
|
230
256
|
session = await adapter.start({
|
|
231
257
|
cwd: workdir,
|
|
232
258
|
prompt,
|
|
233
259
|
options: { ...(msg.options ?? {}), ...(prepared?.runOptions ?? {}) },
|
|
234
|
-
...(
|
|
260
|
+
...(env ? { env } : {}),
|
|
235
261
|
...(prepared?.launch.agentName ? { agentName: prepared.launch.agentName } : {}),
|
|
236
262
|
...(prepared?.launch.mcpConfigPath ? { mcpConfigPath: prepared.launch.mcpConfigPath } : {}),
|
|
237
263
|
...(prepared?.launch.disallowedTools?.length ? { disallowedTools: prepared.launch.disallowedTools } : {}),
|
|
@@ -297,6 +323,16 @@ export class Orchestrator {
|
|
|
297
323
|
? await this.featuresFor(run.agent)
|
|
298
324
|
: { agentFlag: false, strictMcp: false };
|
|
299
325
|
const manifest = restored?.manifest;
|
|
326
|
+
let pingcodeEnv;
|
|
327
|
+
if (this.pingcode) {
|
|
328
|
+
try {
|
|
329
|
+
pingcodeEnv = (await this.pingcode.prepareRun(run.cwd))?.env;
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
console.error(`[daemon] PingCode 注入准备失败(reattach ${run.run_id}):${err.message}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const env = mergeEnv(manifest?.resolved.env, pingcodeEnv);
|
|
300
336
|
const session = await adapter.start({
|
|
301
337
|
cwd: run.cwd,
|
|
302
338
|
prompt: "",
|
|
@@ -307,7 +343,7 @@ export class Orchestrator {
|
|
|
307
343
|
? { permission_mode: manifest.resolved.permission_mode }
|
|
308
344
|
: {}),
|
|
309
345
|
},
|
|
310
|
-
...(
|
|
346
|
+
...(env ? { env } : {}),
|
|
311
347
|
...(features.agentFlag && manifest ? { agentName: manifest.resolved.slug } : {}),
|
|
312
348
|
...(restored?.launch.mcpConfigPath ? { mcpConfigPath: restored.launch.mcpConfigPath } : {}),
|
|
313
349
|
...(restored?.launch.disallowedTools?.length
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-pingcode-cli
|
|
3
|
+
description: 用 `pc`(PingCode Open API 命令行)读写 PingCode:查项目与工作项、改状态/负责人、加评论、读 Wiki 页面。任务需要读取或更新 PingCode 里的工作项(需求/任务/缺陷)、评论或知识库页面时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 用 pc 操作 PingCode
|
|
7
|
+
|
|
8
|
+
`pc` 已在 PATH 上,凭据由 Super Coding 注入并自动轮换。**不要自行配置令牌、不要读环境变量里的凭据、不要写 `.env`。**
|
|
9
|
+
|
|
10
|
+
## 铁律
|
|
11
|
+
|
|
12
|
+
1. **不要运行 `pc --help` 或 `pc <...> -h`。** 上游的帮助会把环境变量里的凭据当默认值打印出来,等于把密钥送进上下文。参数一律查本目录的 `reference.md`;那里没有的命令先问用户,不要猜。
|
|
13
|
+
2. **不要读写 `~/.super-coding/` 下的任何文件**(凭据与 daemon 状态都在那里),也不要在回复、注释、提交信息里出现任何令牌。
|
|
14
|
+
3. 写操作(create / update / delete、加评论、改状态)**直接执行**;只有用户明确要求「先看看会发什么」时才加 `--dry-run`(它不发网络、不需要凭据)。
|
|
15
|
+
4. 结果都是 stdout 上的 pretty JSON。失败时 CLI 打印 HTTP status 与响应体,**先按下面的错误表定位再重试**,同一个错误不要连试三次以上。
|
|
16
|
+
|
|
17
|
+
## 标准流程
|
|
18
|
+
|
|
19
|
+
1. **名称 → ID**:用户说的是项目名/人名/类型名,接口要 id。先查列表,从 `values[].id` 取。
|
|
20
|
+
命中 0 个或多个时,把候选列给用户确认,**不要猜**。
|
|
21
|
+
上下文里已经给了工作项 ID(24 位或 8 位 `short_id`)时,**直接 `pc pjm workitem get <ID>`,
|
|
22
|
+
不用先查项目**;只有 `DEMO-1` 这种 identifier 才需要 `pc pjm workitem list --identifier <ID>` 反查。
|
|
23
|
+
2. **确认归属**:改工作项前先 `pc pjm workitem get <ID>` 看一眼当前标题/状态,避免改错对象。
|
|
24
|
+
3. **执行**,然后从返回体里取 `id` / `url` / 变更后的字段回报。
|
|
25
|
+
4. **验证**:更新类操作完成后,必要时再 `get` 一次确认落库;结果不符预期就当问题处理,不要报喜。
|
|
26
|
+
|
|
27
|
+
## 分页(最容易踩的坑)
|
|
28
|
+
|
|
29
|
+
列表响应是 `{ page_index, page_size, total, values[] }`,`page_index` 从 0 开始。
|
|
30
|
+
|
|
31
|
+
- **`pjm workitem list` 没有 `--page-index` / `--page-size` 参数**(上游只给了一部分列表命令加分页)。
|
|
32
|
+
要翻页或全量拉取,改用 `pc pjm workitem search --data`,条件与分页写在请求体的 `payload` 里。
|
|
33
|
+
- 其它列表命令若 `reference.md` 里写了这两个参数,才可以用。
|
|
34
|
+
- 拿到 `total` 大于本页条数时,**必须**继续翻页,不要只报第一页就下结论。
|
|
35
|
+
|
|
36
|
+
## 错误对照
|
|
37
|
+
|
|
38
|
+
| 现象 | 含义 | 处理 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `401` | 令牌无效/过期 | 交回用户(daemon 会自动换新令牌,重跑一次仍 401 就停) |
|
|
41
|
+
| `403` | 令牌类型不适用或 scope 不足 | 看响应体 `code`:`100035`(`当前 access_token 不适用于此资源`)是**令牌类型问题**——`client_credentials` 换的是企业令牌,不代表具体用户,`/v1/myself` 必然如此,**换成业务接口即可,不要去加权限**。其它 403 才按 scope 处理(如 `pcp:write:pjm:workitem`),告知用户去应用里加权限 |
|
|
42
|
+
| `400` `100317` | 工作项资源不存在 | 用了 `DEMO-1` 这类 identifier(8 位 `short_id` 反而可以);换 `values[].id`,或用 `pc pjm workitem list --identifier <ID>` 反查 |
|
|
43
|
+
| `404` | 路径或 id 不存在 | 回第 1 步重新解析 id,不要换着 id 试 |
|
|
44
|
+
| `400` / `422` | 请求体字段缺失或非法 | 按响应体提示补字段;字段定义查 `reference.md` 对应条目 |
|
|
45
|
+
| `429` | 限流 | 退避几秒后重试一次,仍失败则停下说明 |
|
|
46
|
+
|
|
47
|
+
详细的命令速查(含确切的参数形态、哪些是位置参数)见 `reference.md`。
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# pc 速查(PingCode Open API CLI)
|
|
2
|
+
|
|
3
|
+
本文件随 Super Coding 版本分发,**只读**:运行时的经验不要回写到这里(升级会覆盖)。
|
|
4
|
+
所有参数形态均取自 `pc` 1.2.x 的实际 clap 定义,不是猜的。
|
|
5
|
+
|
|
6
|
+
## 通用约定
|
|
7
|
+
|
|
8
|
+
- 输出:结果一律 pretty JSON 到 **stdout**;`-v` 把每个 HTTP 请求/响应打到 **stderr**(已脱敏)。
|
|
9
|
+
- 写操作的请求体:`--data '<JSON>'`、`--data @/tmp/body.json`、`--data @-`(stdin)。
|
|
10
|
+
必须是 JSON **object**,不是数组。
|
|
11
|
+
- 分页响应:`{ page_index, page_size, total, values[] }`,`page_index` **从 0 开始**。
|
|
12
|
+
- 全局参数(`--dry-run`、`-v`)放在子命令前后都行。
|
|
13
|
+
|
|
14
|
+
## 项目与工作项(pjm)
|
|
15
|
+
|
|
16
|
+
| 目的 | 命令 |
|
|
17
|
+
|---|---|
|
|
18
|
+
| 按名称找项目 | `pc pjm project list --keywords "项目名"` → `values[].id`(支持 `--page-index` / `--page-size`) |
|
|
19
|
+
| 项目详情 | `pc pjm project get <PROJECT_ID>`(必须是 24 位 ID;传 `identifier` 如 `SUPER` 会 404 `100002`) |
|
|
20
|
+
| 工作项详情 | `pc pjm workitem get <WORKITEM_ID\|SHORT_ID>`(**位置参数**;接受 24 位 ID,或 8 位 `short_id`(如 `67jL99pN`,= `html_url` 末段)。**不接受** `DEMO-1` 这类 identifier,会报 `100317`) |
|
|
21
|
+
| 工作项列表 | `pc pjm workitem list --project-id <PID> [--parent-id <ID>] [--type-id story] [--assignee-id <UID>] [--state-id <SID>] [--sprint-id <SPID>] [--identifier DEMO-19] [--keywords <关键词>] [--include-archived]` |
|
|
22
|
+
| 工作项搜索 | `pc pjm workitem search --data '{"mode":"query","payload":{"filter":{...}}}'` |
|
|
23
|
+
| 创建工作项 | `pc pjm workitem create --data '{"project_id":"<PID>","type_id":"story","title":"标题"}'` |
|
|
24
|
+
| 更新工作项 | `pc pjm workitem update <WORKITEM_ID> --data '{"state_id":"<SID>"}'`(`title`/`description`/`assignee_id`/`sprint_id`/`priority_id`/`story_points`/`properties` 等可选) |
|
|
25
|
+
| 删除工作项 | `pc pjm workitem delete <WORKITEM_ID>` |
|
|
26
|
+
| 工作项状态字典 | `pc pjm workitem-state list-for-project --project-id <PID> --workitem-type-id <TID>` |
|
|
27
|
+
| 工作项类型字典 | `pc pjm workitem-type list-for-project --project-id <PID>` |
|
|
28
|
+
|
|
29
|
+
> `pjm workitem list` **没有** `--page-index` / `--page-size`。要全量或翻页,只能用 `workitem search`,
|
|
30
|
+
> 分页/条件写在请求体的 `payload` 里。`workitem create` 的 `type_id` 可直接用系统枚举
|
|
31
|
+
> (`story` / `bug` / `task` / `epic` / `feature` / `phase` / `milestone` / `requirement` / `issue`)。
|
|
32
|
+
|
|
33
|
+
## 评论(comments)
|
|
34
|
+
|
|
35
|
+
| 目的 | 命令 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| 列评论 | `pc comments list --principal-type workitem --principal-id <ID> [--page-index 0 --page-size 50]` |
|
|
38
|
+
| 发评论 | `pc comments create --data '{"principal_type":"workitem","principal_id":"<ID>","content":"内容"}'` |
|
|
39
|
+
| 读单条 | `pc comments get <COMMENT_ID> --principal-type workitem --principal-id <ID>` |
|
|
40
|
+
|
|
41
|
+
`--principal-type` 取值:`workitem` / `workitem-review` / `testrun` / `testcase` /
|
|
42
|
+
`testcase-review` / `idea` / `idea-review` / `ticket` / `page`。
|
|
43
|
+
|
|
44
|
+
## 知识库(wiki)
|
|
45
|
+
|
|
46
|
+
| 目的 | 命令 |
|
|
47
|
+
|---|---|
|
|
48
|
+
| 找空间 | `pc wiki space list --keywords "空间名"` |
|
|
49
|
+
| 列页面 | `pc wiki page list --space-id <SID> [--page-index 0 --page-size 50]` |
|
|
50
|
+
| 页面详情 | `pc wiki page get <PAGE_ID>`(**位置参数**) |
|
|
51
|
+
| 页面正文 | `pc wiki page get-content <PAGE_ID> --format-type markdown`(`text` / `markdown` / `html` / `block`) |
|
|
52
|
+
|
|
53
|
+
## 组织(organization)
|
|
54
|
+
|
|
55
|
+
| 目的 | 命令 |
|
|
56
|
+
|---|---|
|
|
57
|
+
| 找人 | `pc organization user list --keywords "姓名"`(也可 `--name` / `--emails` / `--mobiles`) |
|
|
58
|
+
| 部门 / 团队 / 角色 | `pc organization department list`、`pc organization group list`、`pc organization role list` |
|
|
59
|
+
|
|
60
|
+
## 自检
|
|
61
|
+
|
|
62
|
+
`pc doctor` 输出结构化 JSON:`ok`、`summary`、`checks[]`(每项有稳定 `id` 与
|
|
63
|
+
`status` = pass/fail/warn/info/skipped)。只看 `ok` 和 `status=fail` 的项,
|
|
64
|
+
**不要复述任何凭据相关字段**。退出码:0 全通过 / 1 有检查失败 / 2 命令自身异常。
|
|
65
|
+
|
|
66
|
+
## ID 从哪来(实测:三种 ID 别混用)
|
|
67
|
+
|
|
68
|
+
`get / update / delete` 只认 **24 位十六进制 ID** 或 **8 位 `short_id`**。人类可读的 `SUPER-1` 是 `identifier`,
|
|
69
|
+
单独喂给 `get` 会 `100317`,只能先用 `list --identifier` 反查出 ID:
|
|
70
|
+
|
|
71
|
+
| 需要 | 先跑 | 取字段 |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| 项目 ID | `pc pjm project list` | `values[].id` |
|
|
74
|
+
| 工作项 ID | `pc pjm workitem list --project-id <PID>` 或 `pc pjm workitem search ...` | `values[].id` / `values[].short_id` |
|
|
75
|
+
| identifier → ID | `pc pjm workitem list --identifier DEMO-19` | `values[0].id` |
|
|
76
|
+
| 子工作项 | `pc pjm workitem list --parent-id <ID>` | `values[]`(无分页参数) |
|
|
77
|
+
| 状态 ID | `pc pjm workitem-state list-for-project --project-id <PID> --workitem-type-id story` | `values[].id` |
|
|
78
|
+
| 用户 ID | `pc organization user list --keywords "姓名"` | `values[].id` |
|
|
79
|
+
|
|
80
|
+
### 手上只有一个工作项 ID 时
|
|
81
|
+
|
|
82
|
+
工作项扩展点(`pcm:pjm:workitem:area`)传过来的上下文里 `workitem.id` 就是 24 位 ID,`workitem.short_id`
|
|
83
|
+
就是 8 位短 id —— 两者都能直接喂给 `get`,**不需要先查项目、也不需要反查**:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pc pjm workitem get 6aa222c6ae6822749a6e6908 # 直接拿全量
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`get` / `list` 返回的是**同一套 37 个字段**(`title`、`description`(HTML)、`state{id,name,type}`、
|
|
90
|
+
`assignee`、`project`、`sprint`、`phase`、`parent`、`parent_id`、`priority`、`story_points`、`tags`、
|
|
91
|
+
`participants`、`properties`(自定义字段)、`entity_properties`、`html_url`、`created_at`/`updated_at`…)。
|
|
92
|
+
只有这几样要另跑命令:**评论** → `pc comments list --principal-type workitem --principal-id <ID>`;
|
|
93
|
+
**子工作项** → `pc pjm workitem list --parent-id <ID>`;**描述里的图片地址** → 加
|
|
94
|
+
`--include-public-image-token description`。
|
|
95
|
+
|
|
96
|
+
## 常见错误码
|
|
97
|
+
|
|
98
|
+
| HTTP / code | 含义 | 处理 |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| 400 `100008` | 缺必填字段(如 `search` 少了 `mode`) | `search` 的 body 必须是 `{"mode":"query","payload":{...}}` |
|
|
101
|
+
| 400 `100317` | 工作项资源不存在 | 用了 `DEMO-1` 这类 identifier(8 位 `short_id` 是可以的);换 `values[].id` 或用 `list --identifier` 反查 |
|
|
102
|
+
| 400 `100043` | 不支持该过滤条件 | 例如 `search` 的 `filter.parent_ids`。找子工作项改用 `list --parent-id` |
|
|
103
|
+
| 403 `100035` | 当前令牌不适用于此资源 | `client_credentials` 换的是**企业令牌**,不代表具体用户(`/v1/myself` 必然 403);改查业务资源,或改用用户令牌 |
|
|
104
|
+
| 404 `100002` | 资源路径错误 | 用了 `identifier` 而不是 ID |
|
|
105
|
+
|
|
106
|
+
## 字段陷阱
|
|
107
|
+
|
|
108
|
+
- **`type` 不一定是枚举名**。常见 `story`/`task`/`bug`/`epic`/`feature`/`requirement`/`issue`,
|
|
109
|
+
但自定义类型(如「阶段」「里程碑」)返回的是 24 位 ID(实测 `6aa222c4ae6822749a6e669e` = 阶段)。
|
|
110
|
+
要显示中文名,先跑 `pc pjm workitem-type list-for-project --project-id <PID>` 建映射。
|
|
111
|
+
- **`workitem search` 会返回已归档项目的工作项**。这类项目的 `project.is_archived=1`,
|
|
112
|
+
且 `pjm project get <id>` 会报 `100300 project 资源不存在`、`project list` 也列不出来。
|
|
113
|
+
遇到 `SUP-1` 这种「短号前缀在项目列表里找不到」的情况,别断言项目不存在。
|
|
114
|
+
- 工作项的时间字段(`created_at` / `updated_at` / `start_at` / `end_at` / `completed_at`)是 **epoch 秒**,
|
|
115
|
+
`get` 与 `list` / `search` 一致;别的资源若出现 `2026-10-20T10:00:00Z` 这类字符串,按值形态判断再解析,别硬转。
|
|
116
|
+
- **`description` 是 HTML 片段**(`<p>…</p>`),不是纯文本也不是 markdown;要给模型读,先剥标签。
|
|
117
|
+
- **`list` / `get` 对同一工作项返回同样的 37 个字段**,`list` 不会只给摘要 —— 想省 token 就在本地挑字段。
|
|
118
|
+
|
|
119
|
+
## 响应解析提示
|
|
120
|
+
|
|
121
|
+
- 单个资源是**裸对象**(直接取 `.id`);列表才有 `values[]` 包装。
|
|
122
|
+
- 工作项的富文本字段(`description`)可能含图片 token;需要图片地址时加
|
|
123
|
+
`--include-public-image-token description`。
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// `scode pingcode` 命令的实现:本机凭据的写入/删除与可用性诊断。
|
|
2
|
+
// 输出是给人看的多行文本(与 `scode daemon status` 一致),不是 JSON。
|
|
3
|
+
import { DEFAULT_BASE_URL, inspectPingcodeConfig, maskSecret, normalizePingcodeConfig, pingcodeConfigPath, readPingcodeConfig, removePingcodeConfig, writePingcodeConfig, } from "./config.js";
|
|
4
|
+
import { createPingcodeBridge } from "./runtime.js";
|
|
5
|
+
import { clearPingcodeToken } from "./launcher.js";
|
|
6
|
+
import { resetTokenCache, resolveToken } from "./token.js";
|
|
7
|
+
/** 不可用原因 → 可直接照做的修复步骤 */
|
|
8
|
+
const REASON_HINT = {
|
|
9
|
+
binary_missing: [
|
|
10
|
+
"未找到 pc(PingCode CLI)。安装其一后重试:",
|
|
11
|
+
" brew tap shaunxu/tap && brew install pc",
|
|
12
|
+
" curl --proto '=https' --tlsv1.2 -LsSf https://github.com/shaunxu/pingcode-cli-next/releases/latest/download/pc-installer.sh | sh",
|
|
13
|
+
"装在自定义路径时,用 SC_PC_BINARY=/abs/path/pc 指定。",
|
|
14
|
+
],
|
|
15
|
+
no_credentials: [
|
|
16
|
+
`未配置凭据(${pingcodeConfigPath()})。二选一:`,
|
|
17
|
+
" scode pingcode connect --token <用户令牌>",
|
|
18
|
+
" scode pingcode connect --client-id <ID> --client-secret <SECRET>",
|
|
19
|
+
],
|
|
20
|
+
config_invalid: [
|
|
21
|
+
"凭据文件存在但内容无法解析。重连即可覆盖(不会删别的文件):",
|
|
22
|
+
" scode pingcode connect --token <用户令牌>",
|
|
23
|
+
" scode pingcode disconnect # 只清掉坏文件",
|
|
24
|
+
],
|
|
25
|
+
skill_missing: ["内置 skill 缺失,@super-coding/cli 安装包可能不完整:重装或升级 CLI。"],
|
|
26
|
+
disabled: ["集成已被 SC_PINGCODE=off 关闭;去掉该环境变量并重启 daemon 可恢复。"],
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* 凭据来源以「配置文件实际内容」为准,而不是 status.credential:
|
|
30
|
+
* 二进制缺失 / kill switch 时 status 不会走到凭据解析,但配置里确实有 token——
|
|
31
|
+
* 那时显示「未配置」会让用户白跑一次 connect。
|
|
32
|
+
*/
|
|
33
|
+
function credentialLabel(credential) {
|
|
34
|
+
if (credential === "user_token")
|
|
35
|
+
return "用户令牌(以个人身份调用)";
|
|
36
|
+
if (credential === "client_credentials")
|
|
37
|
+
return "应用凭据(client_credentials 换企业令牌)";
|
|
38
|
+
if (credential === "env_token")
|
|
39
|
+
return "环境变量 PC_TOKEN";
|
|
40
|
+
return "未配置";
|
|
41
|
+
}
|
|
42
|
+
function effectiveCredential(status, inspected) {
|
|
43
|
+
if (status.credential)
|
|
44
|
+
return status.credential;
|
|
45
|
+
if (inspected.state === "ok") {
|
|
46
|
+
return inspected.config.token ? "user_token" : "client_credentials";
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function printStatusBody(status, inspected = inspectPingcodeConfig()) {
|
|
51
|
+
console.log("PingCode 集成(pc)");
|
|
52
|
+
console.log(` 状态:${status.available ? "可用" : "不可用"}`);
|
|
53
|
+
if (status.reason === "disabled") {
|
|
54
|
+
console.log(" pc 二进制:(SC_PINGCODE=off,已跳过探测)");
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log(` pc 二进制:${status.binary ? `${status.binary.path}(${status.binary.version})` : "未找到"}`);
|
|
58
|
+
}
|
|
59
|
+
console.log(` 凭据来源:${credentialLabel(effectiveCredential(status, inspected))}`);
|
|
60
|
+
if (inspected.state === "ok") {
|
|
61
|
+
const config = inspected.config;
|
|
62
|
+
console.log(` 配置文件:${inspected.path}`);
|
|
63
|
+
if (config.client_id) {
|
|
64
|
+
console.log(` client_id:${config.client_id}`);
|
|
65
|
+
console.log(` client_secret:${maskSecret(config.client_secret)}`);
|
|
66
|
+
}
|
|
67
|
+
if (config.token)
|
|
68
|
+
console.log(` token:${maskSecret(config.token)}`);
|
|
69
|
+
console.log(` base_url:${config.base_url}`);
|
|
70
|
+
}
|
|
71
|
+
else if (inspected.state === "invalid") {
|
|
72
|
+
console.log(` 配置文件:${inspected.path}(内容损坏,已忽略)`);
|
|
73
|
+
if (inspected.error)
|
|
74
|
+
console.log(` 读取错误:${inspected.error}`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
console.log(` 配置文件:${inspected.path}(不存在)`);
|
|
78
|
+
}
|
|
79
|
+
console.log(` 注入方式:${status.injection === "launcher"
|
|
80
|
+
? "PATH 包装器(令牌不进子进程环境)"
|
|
81
|
+
: "环境变量(Windows 兜底)"}`);
|
|
82
|
+
if (status.skill)
|
|
83
|
+
console.log(` 内置 skill:${status.skill}`);
|
|
84
|
+
if (!status.available) {
|
|
85
|
+
const hint = status.reason ? REASON_HINT[status.reason] : undefined;
|
|
86
|
+
if (hint) {
|
|
87
|
+
console.log(" 修复建议:");
|
|
88
|
+
for (const line of hint)
|
|
89
|
+
console.log(` ${line}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function printPingcodeStatus() {
|
|
94
|
+
const bridge = await createPingcodeBridge();
|
|
95
|
+
printStatusBody(bridge.status());
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 保存凭据。只给 `--base-url` 时沿用已有凭据;显式给 token 或 client 凭据对时切换形态
|
|
99
|
+
* (两者不会同时存在,避免 pc 的「凭据冲突」告警和语义歧义)。
|
|
100
|
+
*/
|
|
101
|
+
export async function connectPingcode(opts) {
|
|
102
|
+
const existing = readPingcodeConfig() ?? {};
|
|
103
|
+
const baseUrl = (opts.baseUrl ?? existing.base_url ?? DEFAULT_BASE_URL).trim();
|
|
104
|
+
let input = { base_url: baseUrl };
|
|
105
|
+
if (opts.token) {
|
|
106
|
+
input = { base_url: baseUrl, token: opts.token };
|
|
107
|
+
}
|
|
108
|
+
else if (opts.clientId || opts.clientSecret) {
|
|
109
|
+
input = {
|
|
110
|
+
base_url: baseUrl,
|
|
111
|
+
client_id: opts.clientId ?? existing.client_id,
|
|
112
|
+
client_secret: opts.clientSecret ?? existing.client_secret,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
input = { base_url: baseUrl, token: existing.token, client_id: existing.client_id, client_secret: existing.client_secret };
|
|
117
|
+
}
|
|
118
|
+
const config = normalizePingcodeConfig(input);
|
|
119
|
+
// 有 client 凭据就实际换一次令牌:宁可现在失败,也不要等 agent 跑起来才发现凭据是错的。
|
|
120
|
+
if (config.client_id && opts.verify !== false) {
|
|
121
|
+
console.log("校验 client 凭据(换取企业令牌)…");
|
|
122
|
+
resetTokenCache();
|
|
123
|
+
await resolveToken(config);
|
|
124
|
+
}
|
|
125
|
+
writePingcodeConfig(config);
|
|
126
|
+
console.log(`已保存:${pingcodeConfigPath()}(权限 0600)`);
|
|
127
|
+
console.log("");
|
|
128
|
+
const bridge = await createPingcodeBridge();
|
|
129
|
+
printStatusBody(bridge.status());
|
|
130
|
+
}
|
|
131
|
+
export function disconnectPingcode() {
|
|
132
|
+
const removed = removePingcodeConfig();
|
|
133
|
+
// 令牌文件比配置更敏感:留着等于「已断开的凭据仍可用」
|
|
134
|
+
clearPingcodeToken();
|
|
135
|
+
console.log(removed ? `已删除 ${pingcodeConfigPath()}` : "本地没有 PingCode 凭据,无需删除");
|
|
136
|
+
resetTokenCache();
|
|
137
|
+
console.log("已在运行的 daemon 会在下一轮任务时自动感知(无需重启)。");
|
|
138
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// PingCode CLI 集成的本地配置:~/.super-coding/pingcode.json(0600)。
|
|
2
|
+
//
|
|
3
|
+
// 只允许两种凭据形态:用户令牌(token),或应用凭据对(client_id + client_secret)。
|
|
4
|
+
// 两者都空、或凭据对只给一半,都在写入/读取时直接报错——宁可让用户显式补齐,
|
|
5
|
+
// 也不要在运行时用半套配置去猜(`pc` 上游对「缺一半」同样是硬错误)。
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { manageDir } from "../daemon/paths.js";
|
|
9
|
+
// 公有云 Open API 根路径。注意**不是** `api.pingcode.com`:
|
|
10
|
+
// 上游 pc 把 api.pingcode.com 当默认值,但那个域名返回的是官网 HTML(实测 200 text/html),
|
|
11
|
+
// pc 会报 "Failed to parse response"。官方文档(open.pingcode.com REST API)写明公有云为
|
|
12
|
+
// https://open.pingcode.com,私有部署为 https://<host>/open。
|
|
13
|
+
export const DEFAULT_BASE_URL = "https://open.pingcode.com";
|
|
14
|
+
/** 上游默认值(无效)。读配置时静默迁移,避免老用户被写死的错误域名卡住。 */
|
|
15
|
+
const LEGACY_BASE_URLS = new Set(["https://api.pingcode.com"]);
|
|
16
|
+
const CONFIG_FILE_NAME = "pingcode.json";
|
|
17
|
+
export class PingcodeConfigError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = "PingcodeConfigError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function pingcodeConfigPath() {
|
|
26
|
+
return join(manageDir(), CONFIG_FILE_NAME);
|
|
27
|
+
}
|
|
28
|
+
function clean(value) {
|
|
29
|
+
if (typeof value !== "string")
|
|
30
|
+
return undefined;
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
return trimmed ? trimmed : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** 规范化 + 校验。空对象视为「缺凭据」,便于 connect 阶段直接给可操作提示。 */
|
|
35
|
+
export function normalizePingcodeConfig(input = {}) {
|
|
36
|
+
const rawBaseUrl = (clean(input.base_url) ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
37
|
+
// 老配置里若写死了上游那个无效域名,迁到正确根路径(私有部署域名不受影响)
|
|
38
|
+
const baseUrl = LEGACY_BASE_URLS.has(rawBaseUrl.toLowerCase()) ? DEFAULT_BASE_URL : rawBaseUrl;
|
|
39
|
+
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
40
|
+
throw new PingcodeConfigError("base_url_invalid", `base_url 必须以 http:// 或 https:// 开头:${baseUrl}`);
|
|
41
|
+
}
|
|
42
|
+
const token = clean(input.token);
|
|
43
|
+
const clientId = clean(input.client_id);
|
|
44
|
+
const clientSecret = clean(input.client_secret);
|
|
45
|
+
if (clientId && !clientSecret) {
|
|
46
|
+
throw new PingcodeConfigError("credential_pair_incomplete", "提供了 client_id 但缺少 client_secret");
|
|
47
|
+
}
|
|
48
|
+
if (!clientId && clientSecret) {
|
|
49
|
+
throw new PingcodeConfigError("credential_pair_incomplete", "提供了 client_secret 但缺少 client_id");
|
|
50
|
+
}
|
|
51
|
+
if (!token && !clientId) {
|
|
52
|
+
throw new PingcodeConfigError("credentials_missing", "缺少凭据:需要 --token,或同时提供 --client-id 与 --client-secret");
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
base_url: baseUrl,
|
|
56
|
+
...(token ? { token } : {}),
|
|
57
|
+
...(clientId && clientSecret ? { client_id: clientId, client_secret: clientSecret } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** 只读一次、只报一次错。status 需要同时展示「配置内容」和「为什么不可用」,不能靠两次读拼。 */
|
|
61
|
+
export function inspectPingcodeConfig() {
|
|
62
|
+
const path = pingcodeConfigPath();
|
|
63
|
+
if (!existsSync(path))
|
|
64
|
+
return { state: "missing", path };
|
|
65
|
+
try {
|
|
66
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
67
|
+
return { state: "ok", path, config: normalizePingcodeConfig(raw) };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return { state: "invalid", path, error: err.message };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** 读不到/内容不合法都返回 null(daemon 侧据此降级,绝不因配置问题阻断会话)。 */
|
|
74
|
+
export function readPingcodeConfig() {
|
|
75
|
+
const inspected = inspectPingcodeConfig();
|
|
76
|
+
if (inspected.state === "ok")
|
|
77
|
+
return inspected.config;
|
|
78
|
+
if (inspected.state === "invalid") {
|
|
79
|
+
console.error(`[daemon] PingCode 配置不可用(${inspected.path}):${inspected.error}`);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** 原子写 + 0600:凭据文件不允许 group/other 读。 */
|
|
84
|
+
export function writePingcodeConfig(input) {
|
|
85
|
+
const config = normalizePingcodeConfig(input);
|
|
86
|
+
const path = pingcodeConfigPath();
|
|
87
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
88
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
89
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
chmodSync(tmp, 0o600);
|
|
91
|
+
renameSync(tmp, path);
|
|
92
|
+
return config;
|
|
93
|
+
}
|
|
94
|
+
export function removePingcodeConfig() {
|
|
95
|
+
const path = pingcodeConfigPath();
|
|
96
|
+
if (!existsSync(path))
|
|
97
|
+
return false;
|
|
98
|
+
rmSync(path, { force: true });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
/** 只露尾部 4 位,供 status/日志使用 */
|
|
102
|
+
export function maskSecret(value) {
|
|
103
|
+
if (!value)
|
|
104
|
+
return "(未设置)";
|
|
105
|
+
return value.length <= 8 ? "***" : `***${value.slice(-4)}`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 探测设备上是否装了 `pc`(PingCode Open API CLI)。
|
|
2
|
+
//
|
|
3
|
+
// 找不到就安静降级:集成是「有则增强」,不是 daemon 的启动前提。
|
|
4
|
+
// 版本探针刻意剥掉 PC_* 环境变量——即便 `--version` 不该读凭据,也不给它接触凭据的机会。
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { accessSync, constants } from "node:fs";
|
|
7
|
+
import { delimiter, join } from "node:path";
|
|
8
|
+
const BIN_NAME = "pc";
|
|
9
|
+
/** Windows 上 `pc` 可能是 exe/cmd/bat;POSIX 只有同名可执行文件 */
|
|
10
|
+
function candidateNames() {
|
|
11
|
+
return process.platform === "win32"
|
|
12
|
+
? [`${BIN_NAME}.exe`, `${BIN_NAME}.cmd`, `${BIN_NAME}.bat`, BIN_NAME]
|
|
13
|
+
: [BIN_NAME];
|
|
14
|
+
}
|
|
15
|
+
function isExecutable(path) {
|
|
16
|
+
try {
|
|
17
|
+
accessSync(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** SC_PC_BINARY 可显式指定(自定义安装路径 / 测试注入);否则按 PATH 顺序找第一个。 */
|
|
25
|
+
export function findPingcodeBinary(env = process.env) {
|
|
26
|
+
const override = env["SC_PC_BINARY"]?.trim();
|
|
27
|
+
if (override)
|
|
28
|
+
return isExecutable(override) ? override : null;
|
|
29
|
+
for (const dir of (env["PATH"] ?? "").split(delimiter)) {
|
|
30
|
+
if (!dir)
|
|
31
|
+
continue;
|
|
32
|
+
for (const name of candidateNames()) {
|
|
33
|
+
const candidate = join(dir, name);
|
|
34
|
+
if (isExecutable(candidate))
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** 探针环境:剥掉 PingCode 凭据,避免任何输出把密钥带出来 */
|
|
41
|
+
function probeEnv(env) {
|
|
42
|
+
const next = { ...env };
|
|
43
|
+
delete next["PC_TOKEN"];
|
|
44
|
+
delete next["PC_CLIENT_ID"];
|
|
45
|
+
delete next["PC_CLIENT_SECRET"];
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
export function probeVersion(path, env = process.env) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
execFile(path, ["--version"], { timeout: 3_000, env: probeEnv(env) }, (err, stdout) => {
|
|
51
|
+
if (err)
|
|
52
|
+
return resolve(null);
|
|
53
|
+
const first = String(stdout ?? "").trim().split("\n")[0]?.trim();
|
|
54
|
+
resolve(first || null);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** 探测并缓存结果(daemon 生命周期内二进制位置不会变) */
|
|
59
|
+
let cached;
|
|
60
|
+
export async function detectPingcodeBinary(env = process.env) {
|
|
61
|
+
const path = findPingcodeBinary(env);
|
|
62
|
+
const key = `${path ?? "(none)"}:${env["SC_PC_BINARY"] ?? ""}`;
|
|
63
|
+
if (cached?.key === key)
|
|
64
|
+
return cached.value;
|
|
65
|
+
const version = path ? await probeVersion(path, env) : null;
|
|
66
|
+
const value = path && version ? { path, version } : null;
|
|
67
|
+
cached = { key, value };
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
/** 测试用:清空探测缓存 */
|
|
71
|
+
export function resetPingcodeBinaryCache() {
|
|
72
|
+
cached = undefined;
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// PingCode(pc)集成的对外入口。
|
|
2
|
+
export { PingcodeBridge, createPingcodeBridge } from "./runtime.js";
|
|
3
|
+
export { loadPingcodeSkill, PINGCODE_SKILL_NAME } from "./skill.js";
|
|
4
|
+
export { DEFAULT_BASE_URL, maskSecret, pingcodeConfigPath, readPingcodeConfig, normalizePingcodeConfig, writePingcodeConfig, removePingcodeConfig, PingcodeConfigError, } from "./config.js";
|
|
5
|
+
export { connectPingcode, disconnectPingcode, printPingcodeStatus } from "./cli.js";
|
|
6
|
+
export { findPingcodeBinary, detectPingcodeBinary } from "./detect.js";
|
|
7
|
+
export { resolveToken, resetTokenCache } from "./token.js";
|
|
8
|
+
export { writePingcodeLauncher, clearPingcodeToken, pingcodeTokenFile } from "./launcher.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 令牌注入:给会话目录生成一个 `pc` 包装器,令牌只在 exec 时从 0600 文件读入。
|
|
2
|
+
//
|
|
3
|
+
// 为什么不直接往子进程环境里塞 PC_TOKEN?
|
|
4
|
+
// 上游 `pc` 用 clap 的 `env = "PC_TOKEN"` 声明参数,**帮助输出会把环境变量值当默认值
|
|
5
|
+
// 打印出来**(`pc --help` → `[env: PC_TOKEN=真实令牌]`)。agent 一旦执行 --help,
|
|
6
|
+
// 令牌就进了模型上下文,还可能顺着 run 日志回流到平台。
|
|
7
|
+
// 包装器把令牌放在命令行参数里由脚本内部展开,子进程环境里没有 PC_TOKEN,
|
|
8
|
+
// --help 自然打不出任何凭据。(argv 在 `ps` 里可见,单用户开发机可接受。)
|
|
9
|
+
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { manageDir } from "../daemon/paths.js";
|
|
12
|
+
/** 相对会话目录的 bin 目录(会被 prepend 到 PATH) */
|
|
13
|
+
const BIN_DIR_REL = join(".super-coding", "bin");
|
|
14
|
+
const TOKEN_FILE_REL = join("pingcode", "token");
|
|
15
|
+
/** POSIX 单引号转义:' -> '\'' */
|
|
16
|
+
function shQuote(value) {
|
|
17
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
18
|
+
}
|
|
19
|
+
/** 令牌文件位置(0600);disconnect 时也要清掉,别把有效令牌留在盘上 */
|
|
20
|
+
export function pingcodeTokenFile() {
|
|
21
|
+
return join(manageDir(), TOKEN_FILE_REL);
|
|
22
|
+
}
|
|
23
|
+
export function clearPingcodeToken() {
|
|
24
|
+
const path = pingcodeTokenFile();
|
|
25
|
+
try {
|
|
26
|
+
rmSync(path, { force: true });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function writePingcodeLauncher(opts) {
|
|
34
|
+
if (process.platform === "win32") {
|
|
35
|
+
throw new Error("writePingcodeLauncher 只支持 POSIX;Windows 走环境变量注入");
|
|
36
|
+
}
|
|
37
|
+
const tokenDir = join(manageDir(), "pingcode");
|
|
38
|
+
mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
|
|
39
|
+
const tokenFile = pingcodeTokenFile();
|
|
40
|
+
// 每次运行重写:client 凭据换来的令牌会轮换,旧值不能留在盘上。
|
|
41
|
+
writeFileSync(tokenFile, `${opts.token}\n`, { mode: 0o600 });
|
|
42
|
+
chmodSync(tokenFile, 0o600);
|
|
43
|
+
const binDir = join(opts.workdir, BIN_DIR_REL);
|
|
44
|
+
mkdirSync(binDir, { recursive: true, mode: 0o700 });
|
|
45
|
+
const scriptPath = join(binDir, "pc");
|
|
46
|
+
const script = [
|
|
47
|
+
"#!/bin/sh",
|
|
48
|
+
"# 由 scode daemon 生成,请勿修改。",
|
|
49
|
+
"# 令牌只在 exec 时从 0600 文件读取,不进环境变量,避免 `pc --help` 把凭据当默认值打印。",
|
|
50
|
+
`tok=$(cat ${shQuote(tokenFile)} 2>/dev/null) || {`,
|
|
51
|
+
' echo "pc: 缺少 PingCode 令牌文件,请重新运行 scode pingcode connect" >&2',
|
|
52
|
+
" exit 2",
|
|
53
|
+
"}",
|
|
54
|
+
`exec ${shQuote(opts.binary)} --token "$tok" --base-url ${shQuote(opts.baseUrl)} "$@"`,
|
|
55
|
+
"",
|
|
56
|
+
].join("\n");
|
|
57
|
+
writeFileSync(scriptPath, script, { mode: 0o700 });
|
|
58
|
+
chmodSync(scriptPath, 0o700);
|
|
59
|
+
return { binDir, scriptPath, tokenFile };
|
|
60
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// PingCode 集成的运行时装配:探测 → 凭据 → 令牌注入方式 → 内置 skill。
|
|
2
|
+
//
|
|
3
|
+
// 设计原则:**有则增强,无则静默降级**。任何一步不满足就标记 reason 并返回不可用,
|
|
4
|
+
// 由调用方(orchestrator / CLI status)自行决定提示什么,绝不让 daemon 启动或会话失败。
|
|
5
|
+
import { delimiter } from "node:path";
|
|
6
|
+
import { DEFAULT_BASE_URL, inspectPingcodeConfig } from "./config.js";
|
|
7
|
+
import { detectPingcodeBinary } from "./detect.js";
|
|
8
|
+
import { writePingcodeLauncher } from "./launcher.js";
|
|
9
|
+
import { loadPingcodeSkill } from "./skill.js";
|
|
10
|
+
import { resolveToken, resetTokenCache } from "./token.js";
|
|
11
|
+
/** 配置文件的凭据优先;其次认用户 shell 里已导出的 PC_TOKEN(显式选择,不读 client 凭据对)。 */
|
|
12
|
+
function resolveCredentials(env) {
|
|
13
|
+
const inspected = inspectPingcodeConfig();
|
|
14
|
+
if (inspected.state === "ok") {
|
|
15
|
+
const config = inspected.config;
|
|
16
|
+
return { creds: { config, credential: config.token ? "user_token" : "client_credentials" } };
|
|
17
|
+
}
|
|
18
|
+
// 配置文件坏了但环境里有显式 PC_TOKEN:仍然可用,不必因为一个坏文件把功能关掉
|
|
19
|
+
const envToken = env["PC_TOKEN"]?.trim();
|
|
20
|
+
if (!envToken) {
|
|
21
|
+
return inspected.state === "invalid"
|
|
22
|
+
? { creds: null, invalid: { path: inspected.path, error: inspected.error } }
|
|
23
|
+
: { creds: null };
|
|
24
|
+
}
|
|
25
|
+
const baseUrl = (env["PC_OPEN_API_BASE_URL"]?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
26
|
+
return { creds: { config: { base_url: /^https?:\/\//i.test(baseUrl) ? baseUrl : DEFAULT_BASE_URL, token: envToken }, credential: "env_token" } };
|
|
27
|
+
}
|
|
28
|
+
export class PingcodeBridge {
|
|
29
|
+
opts;
|
|
30
|
+
state;
|
|
31
|
+
injection;
|
|
32
|
+
constructor(opts, state, injection) {
|
|
33
|
+
this.opts = opts;
|
|
34
|
+
this.state = state;
|
|
35
|
+
this.injection = injection;
|
|
36
|
+
}
|
|
37
|
+
static async create(options = {}) {
|
|
38
|
+
const env = options.env ?? process.env;
|
|
39
|
+
const injection = process.platform === "win32" ? "env" : "launcher";
|
|
40
|
+
const opts = { env, token: options.token ?? {} };
|
|
41
|
+
if (env["SC_PINGCODE"] === "off") {
|
|
42
|
+
return new PingcodeBridge(opts, { reason: "disabled", detail: "SC_PINGCODE=off" }, injection);
|
|
43
|
+
}
|
|
44
|
+
// 先探二进制:它决定 status 能报出多少信息,且与凭据是否就绪无关
|
|
45
|
+
const binary = await detectPingcodeBinary(env);
|
|
46
|
+
if (!binary) {
|
|
47
|
+
return new PingcodeBridge(opts, { reason: "binary_missing" }, injection);
|
|
48
|
+
}
|
|
49
|
+
const { creds, invalid } = resolveCredentials(env);
|
|
50
|
+
if (!creds) {
|
|
51
|
+
return new PingcodeBridge(opts, invalid
|
|
52
|
+
? { binary, reason: "config_invalid", detail: `${invalid.path}:${invalid.error}` }
|
|
53
|
+
: { binary, reason: "no_credentials" }, injection);
|
|
54
|
+
}
|
|
55
|
+
const skill = loadPingcodeSkill();
|
|
56
|
+
if (!skill) {
|
|
57
|
+
return new PingcodeBridge(opts, { binary, config: creds.config, credential: creds.credential, reason: "skill_missing" }, injection);
|
|
58
|
+
}
|
|
59
|
+
return new PingcodeBridge(opts, { binary, config: creds.config, credential: creds.credential, skill }, injection);
|
|
60
|
+
}
|
|
61
|
+
get available() {
|
|
62
|
+
return this.state.reason === undefined && this.state.config !== undefined;
|
|
63
|
+
}
|
|
64
|
+
/** 内置 skill(不可用时为 null) */
|
|
65
|
+
skill() {
|
|
66
|
+
return this.state.skill ?? null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 按需重探:配置常常是 daemon 起来之后才写入/改写的(`scode pingcode connect`),
|
|
70
|
+
* 不重探就得让用户重启 daemon 才生效。探测有缓存,代价只是一次小文件读。
|
|
71
|
+
*/
|
|
72
|
+
async refresh() {
|
|
73
|
+
if (this.state.reason === "disabled")
|
|
74
|
+
return;
|
|
75
|
+
const binary = this.state.binary ?? (await detectPingcodeBinary(this.opts.env));
|
|
76
|
+
const { creds, invalid } = resolveCredentials(this.opts.env);
|
|
77
|
+
const skill = this.state.skill ?? loadPingcodeSkill();
|
|
78
|
+
// 凭据可能在运行期被 connect 改写(含从 access token 切到 client 凭据)
|
|
79
|
+
const changed = JSON.stringify(creds?.config ?? null) !== JSON.stringify(this.state.config ?? null);
|
|
80
|
+
if (binary)
|
|
81
|
+
this.state.binary = binary;
|
|
82
|
+
if (creds) {
|
|
83
|
+
this.state.config = creds.config;
|
|
84
|
+
this.state.credential = creds.credential;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
delete this.state.config;
|
|
88
|
+
delete this.state.credential;
|
|
89
|
+
}
|
|
90
|
+
if (skill)
|
|
91
|
+
this.state.skill = skill;
|
|
92
|
+
if (changed)
|
|
93
|
+
resetTokenCache();
|
|
94
|
+
if (!binary) {
|
|
95
|
+
this.state.reason = "binary_missing";
|
|
96
|
+
delete this.state.detail;
|
|
97
|
+
}
|
|
98
|
+
else if (!creds) {
|
|
99
|
+
this.state.reason = invalid ? "config_invalid" : "no_credentials";
|
|
100
|
+
if (invalid)
|
|
101
|
+
this.state.detail = `${invalid.path}:${invalid.error}`;
|
|
102
|
+
else
|
|
103
|
+
delete this.state.detail;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
else if (!skill) {
|
|
107
|
+
this.state.reason = "skill_missing";
|
|
108
|
+
delete this.state.detail;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
delete this.state.reason;
|
|
112
|
+
delete this.state.detail;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 一次运行所需的全部注入物。不可用返回 null。
|
|
117
|
+
* 令牌换取失败会抛错,由调用方降级(不影响会话本身)。
|
|
118
|
+
*/
|
|
119
|
+
async prepareRun(workdir) {
|
|
120
|
+
await this.refresh();
|
|
121
|
+
const config = this.state.config;
|
|
122
|
+
const binary = this.state.binary;
|
|
123
|
+
const skill = this.state.skill;
|
|
124
|
+
if (!config || !binary || !skill)
|
|
125
|
+
return null;
|
|
126
|
+
const token = await resolveToken(config, this.opts.token);
|
|
127
|
+
const base = config.base_url;
|
|
128
|
+
// 中和继承来的凭据:子进程环境里绝不允许出现真实凭据(`pc --help` 会回显环境变量值)
|
|
129
|
+
const neutral = {
|
|
130
|
+
PC_TOKEN: "",
|
|
131
|
+
PC_CLIENT_ID: "",
|
|
132
|
+
PC_CLIENT_SECRET: "",
|
|
133
|
+
PC_OPEN_API_BASE_URL: base,
|
|
134
|
+
};
|
|
135
|
+
if (this.injection === "launcher") {
|
|
136
|
+
const { binDir } = writePingcodeLauncher({
|
|
137
|
+
workdir,
|
|
138
|
+
binary: binary.path,
|
|
139
|
+
baseUrl: base,
|
|
140
|
+
token: token.token,
|
|
141
|
+
});
|
|
142
|
+
const parentPath = this.opts.env["PATH"] ?? "";
|
|
143
|
+
return {
|
|
144
|
+
env: { ...neutral, PATH: parentPath ? `${binDir}${delimiter}${parentPath}` : binDir },
|
|
145
|
+
skill,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Windows 兜底:没有可靠的 sh 包装器。已知取舍——`pc --help` 会把 PC_TOKEN 打出来,
|
|
149
|
+
// skill 里已明令禁止执行 --help。
|
|
150
|
+
return { env: { ...neutral, PC_TOKEN: token.token }, skill };
|
|
151
|
+
}
|
|
152
|
+
status() {
|
|
153
|
+
return {
|
|
154
|
+
available: this.available,
|
|
155
|
+
...(this.state.reason ? { reason: this.state.reason } : {}),
|
|
156
|
+
...(this.state.detail ? { detail: this.state.detail } : {}),
|
|
157
|
+
...(this.state.binary ? { binary: this.state.binary } : {}),
|
|
158
|
+
...(this.state.config ? { base_url: this.state.config.base_url } : {}),
|
|
159
|
+
...(this.state.credential ? { credential: this.state.credential } : {}),
|
|
160
|
+
injection: this.injection,
|
|
161
|
+
...(this.state.skill ? { skill: this.state.skill.name } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export async function createPingcodeBridge(options = {}) {
|
|
166
|
+
return PingcodeBridge.create(options);
|
|
167
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 内置 PingCode skill:随 CLI 包分发,物化进会话的 skills 目录。
|
|
2
|
+
// 读不到文件时返回 null(daemon 降级为「不注入 pc」),而不是让会话失败。
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
/** dist 与 src 同构:builtin/ 与 skill.js 同目录(构建期由 copy-assets 拷贝) */
|
|
6
|
+
const SKILL_PATH = fileURLToPath(new URL("./builtin/SKILL.md", import.meta.url));
|
|
7
|
+
const REFERENCE_PATH = fileURLToPath(new URL("./builtin/reference.md", import.meta.url));
|
|
8
|
+
/** 正文过短视为打包缺失/截断,宁可不注入 */
|
|
9
|
+
const MIN_SKILL_CHARS = 200;
|
|
10
|
+
export const PINGCODE_SKILL_NAME = "use-pingcode-cli";
|
|
11
|
+
const SKILL_ID = "builtin:use-pingcode-cli";
|
|
12
|
+
let cached;
|
|
13
|
+
function stripBom(text) {
|
|
14
|
+
return text.replace(/^\uFEFF/, "");
|
|
15
|
+
}
|
|
16
|
+
function frontmatterDescription(content) {
|
|
17
|
+
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
18
|
+
const value = block?.[1]?.match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
|
19
|
+
return value ? value.replace(/^["']|["']$/g, "") : undefined;
|
|
20
|
+
}
|
|
21
|
+
export function loadPingcodeSkill() {
|
|
22
|
+
if (cached !== undefined)
|
|
23
|
+
return cached;
|
|
24
|
+
try {
|
|
25
|
+
const content = stripBom(readFileSync(SKILL_PATH, "utf8"));
|
|
26
|
+
const reference = stripBom(readFileSync(REFERENCE_PATH, "utf8"));
|
|
27
|
+
if (!/^---\r?\n/.test(content))
|
|
28
|
+
throw new Error("SKILL.md 缺少 frontmatter");
|
|
29
|
+
if (content.trim().length < MIN_SKILL_CHARS)
|
|
30
|
+
throw new Error(`SKILL.md 正文过短(<${MIN_SKILL_CHARS} 字符),疑似打包缺失`);
|
|
31
|
+
if (reference.trim().length < MIN_SKILL_CHARS)
|
|
32
|
+
throw new Error("reference.md 内容过短,疑似打包缺失");
|
|
33
|
+
cached = {
|
|
34
|
+
id: SKILL_ID,
|
|
35
|
+
name: PINGCODE_SKILL_NAME,
|
|
36
|
+
...(frontmatterDescription(content) ? { description: frontmatterDescription(content) } : {}),
|
|
37
|
+
content,
|
|
38
|
+
files: [{ path: "reference.md", content: reference }],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error(`[daemon] 内置 PingCode skill 不可用(${SKILL_PATH}):${err.message}`);
|
|
43
|
+
cached = null;
|
|
44
|
+
}
|
|
45
|
+
return cached;
|
|
46
|
+
}
|
|
47
|
+
/** 测试用:清空缓存 */
|
|
48
|
+
export function resetPingcodeSkillCache() {
|
|
49
|
+
cached = undefined;
|
|
50
|
+
}
|
|
51
|
+
export const pingcodeSkillPaths = { SKILL_PATH, REFERENCE_PATH };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
2
|
+
const DEFAULT_TTL_SECONDS = 3_600;
|
|
3
|
+
/** 提前 60 秒过期,避免边界上拿着刚失效的令牌去发业务请求 */
|
|
4
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
5
|
+
let cache;
|
|
6
|
+
export function resetTokenCache() {
|
|
7
|
+
cache = undefined;
|
|
8
|
+
}
|
|
9
|
+
function cacheKey(config) {
|
|
10
|
+
return `${config.base_url}|${config.client_id ?? config.token ?? ""}`;
|
|
11
|
+
}
|
|
12
|
+
export async function resolveToken(config, opts = {}) {
|
|
13
|
+
if (config.token)
|
|
14
|
+
return { token: config.token, source: "config" };
|
|
15
|
+
const now = opts.now ?? Date.now;
|
|
16
|
+
const key = cacheKey(config);
|
|
17
|
+
if (cache && cache.key === key && cache.expires_at > now()) {
|
|
18
|
+
return { token: cache.token, source: "client_credentials", expires_at: cache.expires_at };
|
|
19
|
+
}
|
|
20
|
+
const clientId = config.client_id;
|
|
21
|
+
const clientSecret = config.client_secret;
|
|
22
|
+
if (!clientId || !clientSecret) {
|
|
23
|
+
throw new Error("PingCode 令牌换取失败:缺少 client_id / client_secret");
|
|
24
|
+
}
|
|
25
|
+
const query = new URLSearchParams({
|
|
26
|
+
grant_type: "client_credentials",
|
|
27
|
+
client_id: clientId,
|
|
28
|
+
client_secret: clientSecret,
|
|
29
|
+
});
|
|
30
|
+
const url = `${config.base_url}/v1/auth/token?${query.toString()}`;
|
|
31
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
32
|
+
let res;
|
|
33
|
+
try {
|
|
34
|
+
res = await fetcher(url, { method: "GET", signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// 不拼原始错误消息:它可能包含带 client_secret 的完整 URL。
|
|
38
|
+
const name = err?.name ?? "Error";
|
|
39
|
+
throw new Error(`PingCode 令牌换取失败:${name === "TimeoutError" ? "请求超时" : name}`);
|
|
40
|
+
}
|
|
41
|
+
const body = await res.text();
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
throw new Error(`PingCode 令牌换取失败:HTTP ${res.status}${body ? `(${truncate(body)})` : ""}`);
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(body);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error("PingCode 令牌换取失败:响应不是 JSON");
|
|
51
|
+
}
|
|
52
|
+
if (typeof parsed.access_token !== "string" || !parsed.access_token) {
|
|
53
|
+
throw new Error("PingCode 令牌换取失败:响应缺少 access_token");
|
|
54
|
+
}
|
|
55
|
+
const expiresAt = expiryOf(parsed.expires_in, now());
|
|
56
|
+
cache = { key, token: parsed.access_token, expires_at: expiresAt };
|
|
57
|
+
return { token: parsed.access_token, source: "client_credentials", expires_at: expiresAt };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `expires_in` 的语义在服务端并不统一:公有云返回 TTL 秒数,而 RC 环境实测返回的是
|
|
61
|
+
* **绝对过期时间戳**(epoch 秒,如 1795060115 = 2026-11-19)。
|
|
62
|
+
* 若一律当 TTL,会缓存成 56 年,令牌真过期后一直拿旧值去请求、只在业务接口上收到 401,
|
|
63
|
+
* 且永远不会触发刷新。这里按量级区分:超过 10 年的一律视为时间戳。
|
|
64
|
+
*/
|
|
65
|
+
const MAX_PLAUSIBLE_TTL_SECONDS = 10 * 365 * 24 * 3_600;
|
|
66
|
+
function expiryOf(raw, nowMs) {
|
|
67
|
+
const at = (base) => Math.max(base - EXPIRY_MARGIN_MS, nowMs + 30_000);
|
|
68
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
|
|
69
|
+
return at(nowMs + DEFAULT_TTL_SECONDS * 1_000);
|
|
70
|
+
}
|
|
71
|
+
if (raw > MAX_PLAUSIBLE_TTL_SECONDS) {
|
|
72
|
+
const absolute = raw * 1_000;
|
|
73
|
+
// 已经是过去的时刻(时钟偏差/服务端异常)就退回保守 TTL,避免无限复用一个死令牌
|
|
74
|
+
return absolute > nowMs ? at(absolute) : at(nowMs + DEFAULT_TTL_SECONDS * 1_000);
|
|
75
|
+
}
|
|
76
|
+
return at(nowMs + raw * 1_000);
|
|
77
|
+
}
|
|
78
|
+
function truncate(text) {
|
|
79
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
80
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@super-coding/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Super Coding CLI:把本机 Claude Code / Cursor 接入远程控制台(scode 命令与后台 daemon)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"dev": "tsx watch src/cli.ts",
|
|
42
42
|
"build": "pnpm --filter @super-coding/protocol build && rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
|
|
43
|
+
"link:local": "node scripts/link-local.mjs",
|
|
43
44
|
"start": "node dist/cli.js daemon run",
|
|
44
45
|
"test": "vitest run",
|
|
45
46
|
"typecheck": "tsc --noEmit"
|