@yijiancaotang/cli 0.0.0-stage → 1.0.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/README.md +279 -2
- package/bin/caotang.cjs +15 -0
- package/dist/agent-identity.js +14 -0
- package/dist/auth.js +337 -0
- package/dist/capability-result.js +80 -0
- package/dist/cli.js +248 -0
- package/dist/command-help.js +100 -0
- package/dist/commands.js +350 -0
- package/dist/config.js +14 -0
- package/dist/connection-copy.js +40 -0
- package/dist/context.js +30 -0
- package/dist/contracts.js +1 -0
- package/dist/create-bundle.js +103 -0
- package/dist/create-file-failure.js +18 -0
- package/dist/credential-store.js +594 -0
- package/dist/doctor.js +214 -0
- package/dist/errors.js +214 -0
- package/dist/generated/knowledge-growth.js +37 -0
- package/dist/habit-day.js +58 -0
- package/dist/habit-read.js +30 -0
- package/dist/habit-wire.js +56 -0
- package/dist/http.js +201 -0
- package/dist/input-validation.js +76 -0
- package/dist/installation.js +491 -0
- package/dist/knowledge-growth.js +85 -0
- package/dist/local-commands.js +136 -0
- package/dist/local-lock.js +245 -0
- package/dist/local-types.js +3 -0
- package/dist/missing-sections.js +71 -0
- package/dist/onboarding.js +48 -0
- package/dist/output.js +52 -0
- package/dist/pending-requests.js +45 -0
- package/dist/platforms.js +200 -0
- package/dist/query-daily.js +70 -0
- package/dist/query-search.js +42 -0
- package/dist/query-validation.js +45 -0
- package/dist/skill-assets.js +136 -0
- package/dist/upload.js +130 -0
- package/dist/web-citations.js +68 -0
- package/dist/write-permissions.js +41 -0
- package/package.json +27 -4
- package/skills/caotang-context/SKILL.md +47 -0
- package/skills/caotang-first-connect/SKILL.md +62 -0
- package/skills/caotang-project-progress/SKILL.md +87 -0
- package/skills/caotang-save-knowledge/SKILL.md +40 -0
- package/skills/caotang-search/SKILL.md +68 -0
- package/skills/caotang-workday/SKILL.md +34 -0
- package/skills/manifest.json +11 -0
package/dist/auth.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, normalize } from "node:path";
|
|
3
|
+
import { CliError, expectOK, apiError, asRecord, validToken, legalConsentRequiredError } from "./errors.js";
|
|
4
|
+
import { approvalMessage, connectionCopy } from "./connection-copy.js";
|
|
5
|
+
import { productionOrigin } from "./config.js";
|
|
6
|
+
const prefix = "/api/v1/agent";
|
|
7
|
+
const recovery = "请先到网页的 Agent 管理核对是否已生成连接,并用 caotang auth status 核对本机。若网页已有但本机未保存,请由本人确认吊销该连接后再决定是否重新接入;不要自动重试领取令牌或补签。";
|
|
8
|
+
const deliveryUnknown = () => new CliError("authorization_delivery_unknown", "暂时无法确认是否已接好,连接可能已经生成。", recovery, 6, { user_message: connectionCopy.uncertain });
|
|
9
|
+
export function credentialTarget(ctx, failure) {
|
|
10
|
+
const path = ctx.store.path;
|
|
11
|
+
if (typeof path !== "string" || path.length > 4096 || !isAbsolute(path) || normalize(path) !== path || /[\x00-\x1f\x7f]/.test(path))
|
|
12
|
+
return {};
|
|
13
|
+
const current = dirname(path), legacy = join(dirname(current), ".caotang");
|
|
14
|
+
const reported = asRecord(asRecord(failure)?.details)?.target_directory;
|
|
15
|
+
return { target_directory: reported === current || reported === legacy ? reported : current };
|
|
16
|
+
}
|
|
17
|
+
function authorizationCode(value) {
|
|
18
|
+
const body = asRecord(value);
|
|
19
|
+
if (body?.code !== undefined && body.error !== undefined && body.code !== body.error)
|
|
20
|
+
throw deliveryUnknown();
|
|
21
|
+
return body?.code ?? body?.error;
|
|
22
|
+
}
|
|
23
|
+
function hasAuthorizationErrorStatus(value) {
|
|
24
|
+
const body = asRecord(value);
|
|
25
|
+
return body !== null && (Object.hasOwn(body, "code") || Object.hasOwn(body, "error"));
|
|
26
|
+
}
|
|
27
|
+
const invalid = () => new CliError("invalid_auth_response", "无法确认授权响应。", recovery, 7);
|
|
28
|
+
const cancelled = () => new CliError("authorization_cancelled", "授权操作已取消。", "请先在网页核对实际状态,再决定是否重新授权。", 130);
|
|
29
|
+
const expired = () => new CliError("expired_token", "确认码已过期,这次没有完成接入。", "需要接入时,请由本人重新运行 setup 或 auth login,取得新确认码后批准。", 2, { user_message: connectionCopy.expired });
|
|
30
|
+
const id = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
31
|
+
const scope = (value) => value === "read_only" || value === "read_write";
|
|
32
|
+
const date = (value) => typeof value === "string" && /^\d{4}-\d\d-\d\dT/.test(value) && Number.isFinite(Date.parse(value));
|
|
33
|
+
const label = (value) => typeof value === "string" && value.trim() === value && [...value].length >= 1 && [...value].length <= 80 && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\t\n\r]/u.test(value) && !/[^\S ]/u.test(value);
|
|
34
|
+
const checkAbort = (ctx) => { if (ctx.signal?.aborted)
|
|
35
|
+
throw cancelled(); };
|
|
36
|
+
function origin(ctx) {
|
|
37
|
+
let url;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(ctx.api.baseUrl);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw invalid();
|
|
43
|
+
}
|
|
44
|
+
if (url.username || url.password || url.search || url.hash || !["https:", "http:"].includes(url.protocol) || ctx.store.origin !== url.origin)
|
|
45
|
+
throw invalid();
|
|
46
|
+
return url.origin;
|
|
47
|
+
}
|
|
48
|
+
async function request(ctx, path, options) {
|
|
49
|
+
checkAbort(ctx);
|
|
50
|
+
try {
|
|
51
|
+
return await ctx.api.request(path, { ...options, signal: ctx.signal });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
checkAbort(ctx);
|
|
55
|
+
if (error instanceof CliError)
|
|
56
|
+
throw error;
|
|
57
|
+
throw new CliError("network_error", "无法确认授权请求的结果。", "请先在网页核对实际状态,不要直接重复授权。", 6);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function resolveCredential(ctx) {
|
|
61
|
+
checkAbort(ctx);
|
|
62
|
+
origin(ctx);
|
|
63
|
+
if (ctx.tokenOverride !== undefined) {
|
|
64
|
+
if (!validToken(ctx.tokenOverride))
|
|
65
|
+
throw new CliError("invalid_token", "环境中的授权令牌格式无效。", "请检查或取消当前环境的令牌覆盖;覆盖无效时不会回退读取文件。", 3);
|
|
66
|
+
return { token: ctx.tokenOverride, source: "environment" };
|
|
67
|
+
}
|
|
68
|
+
const record = await ctx.store.load();
|
|
69
|
+
if (!record)
|
|
70
|
+
throw new CliError("unauthorized", "当前环境尚未授权。", "请用你自己的名字运行 setup --agent <你的名字>,再由本人在网页批准。", 3);
|
|
71
|
+
return { token: record.token, source: "file" };
|
|
72
|
+
}
|
|
73
|
+
function identity(value, secrets = []) {
|
|
74
|
+
const body = asRecord(value);
|
|
75
|
+
if (!body || !id(body.user_id) || !id(body.token_id) || !scope(body.scope) || (body.kind !== "device" && body.kind !== "manual"))
|
|
76
|
+
throw invalid();
|
|
77
|
+
const forbidden = [...secrets, body.access_token, body.device_code].filter((item) => typeof item === "string" && item.length > 0);
|
|
78
|
+
if ([body.user_id, body.token_id].some((value) => forbidden.some((secret) => value.includes(secret))))
|
|
79
|
+
throw invalid();
|
|
80
|
+
const display = body.account_display_name;
|
|
81
|
+
const safeDisplay = typeof display === "string" && label(display) && [...display].length <= 40
|
|
82
|
+
&& /^[\p{L}\p{N}\p{M} ._-]+$/u.test(display) && !forbidden.some(secret => display.includes(secret)) ? display : undefined;
|
|
83
|
+
return { user_id: body.user_id, token_id: body.token_id, kind: body.kind, scope: body.scope, ...(safeDisplay ? { account_display_name: safeDisplay } : {}) };
|
|
84
|
+
}
|
|
85
|
+
async function validate(ctx, credential, allowPendingConsent = false) {
|
|
86
|
+
const response = await request(ctx, `${prefix}/mcp-token/validate`, { method: "POST", token: credential.token });
|
|
87
|
+
if (response.status === 401)
|
|
88
|
+
throw new CliError("unauthorized", "这个 AI 的连接已断开,请重新接入。", "请带同一个 --agent <你的名字> 运行 setup,由本人批准;其它 AI 的连接不受影响,不要输出凭据。", 3, { http_status: 401, user_message: connectionCopy.disconnected });
|
|
89
|
+
const result = expectOK(response);
|
|
90
|
+
if (result.status !== 200 || hasAuthorizationErrorStatus(result.body))
|
|
91
|
+
throw invalid();
|
|
92
|
+
const who = identity(result.body, [credential.token]);
|
|
93
|
+
const pending = asRecord(result.body)?.legal_consent_required;
|
|
94
|
+
if (pending !== undefined && typeof pending !== "boolean")
|
|
95
|
+
throw invalid();
|
|
96
|
+
if (pending === true && !allowPendingConsent)
|
|
97
|
+
throw legalConsentRequiredError(result.status);
|
|
98
|
+
return who;
|
|
99
|
+
}
|
|
100
|
+
function authenticated(who, credential) {
|
|
101
|
+
return { status: 200, body: { status: "authenticated", source: credential.source, ...who, user_message: connectionCopy.connected } };
|
|
102
|
+
}
|
|
103
|
+
export async function authStatus(ctx) {
|
|
104
|
+
const credential = await resolveCredential(ctx);
|
|
105
|
+
const result = authenticated(await validate(ctx, credential), credential);
|
|
106
|
+
return { ...result, body: { ...asRecord(result.body), environment: ctx.environment ?? (ctx.api.baseUrl === "https://test.gtcai.cn" ? "test" : ctx.api.baseUrl === productionOrigin ? "production" : "unknown") } };
|
|
107
|
+
}
|
|
108
|
+
async function wait(ctx, ms) {
|
|
109
|
+
checkAbort(ctx);
|
|
110
|
+
if (!ctx.sleep) {
|
|
111
|
+
try {
|
|
112
|
+
await delay(ms, undefined, { signal: ctx.signal });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw cancelled();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
let listener;
|
|
120
|
+
try {
|
|
121
|
+
await Promise.race([
|
|
122
|
+
ctx.sleep(ms),
|
|
123
|
+
new Promise((_, reject) => {
|
|
124
|
+
listener = () => reject(cancelled());
|
|
125
|
+
ctx.signal?.addEventListener("abort", listener, { once: true });
|
|
126
|
+
if (ctx.signal?.aborted)
|
|
127
|
+
listener();
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
if (listener)
|
|
133
|
+
ctx.signal?.removeEventListener("abort", listener);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
checkAbort(ctx);
|
|
137
|
+
}
|
|
138
|
+
function challenge(value, apiOrigin) {
|
|
139
|
+
const body = asRecord(value);
|
|
140
|
+
if (!body || typeof body.device_code !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(body.device_code)
|
|
141
|
+
|| Buffer.from(body.device_code, "base64url").length !== 32 || Buffer.from(body.device_code, "base64url").toString("base64url") !== body.device_code
|
|
142
|
+
|| typeof body.user_code !== "string" || !/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}$/.test(body.user_code)
|
|
143
|
+
|| body.verification_url !== `${apiOrigin}/app/device-auth`
|
|
144
|
+
|| !Number.isInteger(body.expires_in) || Number(body.expires_in) < 1 || Number(body.expires_in) > 600
|
|
145
|
+
|| !Number.isInteger(body.interval) || Number(body.interval) < 5 || Number(body.interval) > 600)
|
|
146
|
+
throw invalid();
|
|
147
|
+
return { deviceCode: body.device_code, interval: Number(body.interval), public: { status: "authorization_pending", user_code: body.user_code, verification_url: body.verification_url, expires_in: Number(body.expires_in), browser_opened: false } };
|
|
148
|
+
}
|
|
149
|
+
function delivered(value, deviceName, clientName, deviceCode) {
|
|
150
|
+
const body = asRecord(value), metadata = asRecord(body?.token);
|
|
151
|
+
if (!body || !validToken(body.access_token) || body.token_type !== "Bearer" || !scope(body.scope)
|
|
152
|
+
|| !metadata || !id(metadata.id) || !id(metadata.user_id) || metadata.kind !== "device" || metadata.scope !== body.scope
|
|
153
|
+
|| !date(metadata.created_at) || !label(metadata.device_name) || !label(metadata.client_name)
|
|
154
|
+
|| metadata.device_name !== deviceName || metadata.client_name !== clientName
|
|
155
|
+
|| (metadata.revoked_at !== undefined && metadata.revoked_at !== "")
|
|
156
|
+
|| (metadata.last_used_at !== undefined && !date(metadata.last_used_at)))
|
|
157
|
+
throw invalid();
|
|
158
|
+
return { token: body.access_token, who: identity({ user_id: metadata.user_id, token_id: metadata.id, kind: "device", scope: body.scope }, [body.access_token, deviceCode]) };
|
|
159
|
+
}
|
|
160
|
+
function retryAfter(...values) {
|
|
161
|
+
const present = values.filter((value) => value !== undefined);
|
|
162
|
+
if (present.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0))
|
|
163
|
+
throw invalid();
|
|
164
|
+
return Math.max(0, ...present);
|
|
165
|
+
}
|
|
166
|
+
export async function authLogin(ctx, options = {}) {
|
|
167
|
+
const apiOrigin = origin(ctx);
|
|
168
|
+
checkAbort(ctx);
|
|
169
|
+
if (ctx.tokenOverride === undefined)
|
|
170
|
+
await ctx.store.migrate?.();
|
|
171
|
+
let existing;
|
|
172
|
+
try {
|
|
173
|
+
existing = await resolveCredential(ctx);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (!(error instanceof CliError) || error.code !== "unauthorized")
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
if (existing) {
|
|
180
|
+
try {
|
|
181
|
+
const result = authenticated(await validate(ctx, existing), existing);
|
|
182
|
+
return { ...result, body: { ...asRecord(result.body), authorization_attempted: false, new_authorization_confirmed: false, user_message: connectionCopy.repeated } };
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (!(error instanceof CliError) || error.exitCode !== 3 || asRecord(error.details)?.http_status !== 401 || existing.source === "environment")
|
|
186
|
+
throw error;
|
|
187
|
+
await ctx.store.remove(existing.token);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const deviceName = options.device_name?.trim() ?? ctx.agent?.name ?? "这台电脑";
|
|
191
|
+
const clientName = options.client_name?.trim() ?? ctx.agent?.name ?? "caotang";
|
|
192
|
+
if (!label(deviceName) || !label(clientName))
|
|
193
|
+
throw new CliError("invalid_device_name", "设备名和请求方名称须为1至80个可显示字符。", "请使用简短名称,不要填入密码或令牌。");
|
|
194
|
+
const now = ctx.now ?? Date.now;
|
|
195
|
+
const started = now();
|
|
196
|
+
if (!Number.isFinite(started))
|
|
197
|
+
throw invalid();
|
|
198
|
+
const startResponse = await request(ctx, `${prefix}/device-auth/start`, { method: "POST", body: { device_name: deviceName, client_name: clientName } });
|
|
199
|
+
const startCode = authorizationCode(startResponse.body);
|
|
200
|
+
if (startResponse.status === 403 && startCode === "access_denied")
|
|
201
|
+
throw new CliError("access_denied", "这次接入已取消,没有建立新连接。", "只有本人需要时才重新发起接入。", 4, { user_message: connectionCopy.denied });
|
|
202
|
+
const response = expectOK(startResponse);
|
|
203
|
+
if (response.status !== 200)
|
|
204
|
+
throw invalid();
|
|
205
|
+
if (hasAuthorizationErrorStatus(response.body))
|
|
206
|
+
throw deliveryUnknown();
|
|
207
|
+
const grant = challenge(response.body, apiOrigin);
|
|
208
|
+
const expiresMs = grant.public.expires_in * 1000;
|
|
209
|
+
let waited = 0, interval = grant.interval, nextWait = Math.max(interval, retryAfter(response.retryAfterSeconds));
|
|
210
|
+
let consecutiveNetworkFailures = 0, uncertainDelivery = false;
|
|
211
|
+
const elapsed = () => Math.max(waited, now() - started);
|
|
212
|
+
if (!options.noOpen && ctx.openBrowser) {
|
|
213
|
+
checkAbort(ctx);
|
|
214
|
+
try {
|
|
215
|
+
grant.public.browser_opened = await ctx.openBrowser(grant.public.verification_url) === true;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
grant.public.browser_opened = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
checkAbort(ctx);
|
|
222
|
+
ctx.emitChallenge({ ...grant.public, user_message: approvalMessage(grant.public.verification_url, grant.public.user_code) });
|
|
223
|
+
while (true) {
|
|
224
|
+
const remaining = expiresMs - elapsed();
|
|
225
|
+
if (!Number.isFinite(remaining))
|
|
226
|
+
throw invalid();
|
|
227
|
+
if (remaining <= 0)
|
|
228
|
+
throw uncertainDelivery ? deliveryUnknown() : expired();
|
|
229
|
+
const pause = Math.min(nextWait * 1000, remaining);
|
|
230
|
+
await wait(ctx, pause);
|
|
231
|
+
waited += pause;
|
|
232
|
+
if (elapsed() >= expiresMs)
|
|
233
|
+
throw uncertainDelivery ? deliveryUnknown() : expired();
|
|
234
|
+
let result;
|
|
235
|
+
try {
|
|
236
|
+
result = await request(ctx, `${prefix}/device-auth/poll`, { method: "POST", body: { device_code: grant.deviceCode } });
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
checkAbort(ctx);
|
|
240
|
+
uncertainDelivery = true;
|
|
241
|
+
// Re-query the same one-time grant only. A consumed response remains
|
|
242
|
+
// terminal, and an unknown result never starts or replaces a grant.
|
|
243
|
+
if (!(error instanceof CliError) || error.code !== "network_error" || ++consecutiveNetworkFailures >= 3)
|
|
244
|
+
throw deliveryUnknown();
|
|
245
|
+
nextWait = Math.max(nextWait, interval * consecutiveNetworkFailures);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (result.status >= 500 && result.status <= 599) {
|
|
249
|
+
uncertainDelivery = true;
|
|
250
|
+
if (++consecutiveNetworkFailures >= 3)
|
|
251
|
+
throw deliveryUnknown();
|
|
252
|
+
nextWait = Math.max(nextWait, interval * consecutiveNetworkFailures, retryAfter(result.retryAfterSeconds));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const code = authorizationCode(result.body);
|
|
256
|
+
if (result.status === 200) {
|
|
257
|
+
if (hasAuthorizationErrorStatus(result.body))
|
|
258
|
+
throw deliveryUnknown();
|
|
259
|
+
const token = delivered(result.body, deviceName, clientName, grant.deviceCode);
|
|
260
|
+
try {
|
|
261
|
+
await ctx.store.save({ token: token.token, tokenId: token.who.token_id, scope: token.who.scope, kind: "device", origin: apiOrigin, savedAt: new Date(now()).toISOString() });
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (error instanceof CliError && error.code === "installation_permission_denied") {
|
|
265
|
+
throw new CliError("installation_permission_denied", "设备令牌已交付,但没有权限保存到本地凭据目录。", recovery, 4, { ...credentialTarget(ctx, error), credential_delivery_confirmed: true });
|
|
266
|
+
}
|
|
267
|
+
const links = error instanceof CliError && error.code === "credential_unsafe" && asRecord(error.details)?.reason === "multiple_links";
|
|
268
|
+
throw new CliError("credential_save_failed", links ? "设备令牌已交付,但凭据文件的链接数不是 1,未能安全保存。" : "设备令牌已交付,但未能安全保存。", recovery, 2, links ? { cause: "credential_unsafe", reason: "multiple_links" } : undefined);
|
|
269
|
+
}
|
|
270
|
+
const confirmed = authenticated(token.who, { source: "file" });
|
|
271
|
+
return { ...confirmed, body: { ...asRecord(confirmed.body), authorization_attempted: true, new_authorization_confirmed: true } };
|
|
272
|
+
}
|
|
273
|
+
const body = asRecord(result.body);
|
|
274
|
+
if ((result.status === 202 && code === "authorization_pending") || (result.status === 429 && (code === "slow_down" || code === "device_auth_rate_limited"))) {
|
|
275
|
+
if (body?.interval !== undefined && (!Number.isInteger(body.interval) || Number(body.interval) < 5 || Number(body.interval) > 600))
|
|
276
|
+
throw invalid();
|
|
277
|
+
interval = Math.max(interval + (code === "slow_down" ? 5 : 0), Number(body?.interval ?? 5));
|
|
278
|
+
nextWait = Math.max(interval, retryAfter(result.retryAfterSeconds, body?.retry_after_seconds));
|
|
279
|
+
consecutiveNetworkFailures = 0;
|
|
280
|
+
if (code === "authorization_pending")
|
|
281
|
+
uncertainDelivery = false;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (result.status >= 200 && result.status < 300)
|
|
285
|
+
throw invalid();
|
|
286
|
+
if (result.status === 403 && code === "access_denied")
|
|
287
|
+
throw new CliError("access_denied", "这次接入已取消,没有建立新连接。", "只有本人需要时才重新发起接入。", 4, { user_message: connectionCopy.denied });
|
|
288
|
+
if (result.status === 410 && code === "expired_token")
|
|
289
|
+
throw expired();
|
|
290
|
+
throw apiError(result);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
export async function authLogout(ctx) {
|
|
294
|
+
const credential = await resolveCredential(ctx);
|
|
295
|
+
const removeMatching = async () => { if (credential.source === "file")
|
|
296
|
+
await ctx.store.remove(credential.token); };
|
|
297
|
+
const cleanupUnauthorized = async (error) => {
|
|
298
|
+
try {
|
|
299
|
+
await removeMatching();
|
|
300
|
+
return error;
|
|
301
|
+
}
|
|
302
|
+
catch (cleanupError) {
|
|
303
|
+
if (cleanupError instanceof CliError && cleanupError.code === "installation_permission_denied") {
|
|
304
|
+
return new CliError("installation_permission_denied", "授权已失效,但没有权限清理本地凭据。", "请勿继续使用;先核对目标目录的清空或删除限制,不要重复吊销、自动重新授权或输出凭据。", 4, { ...credentialTarget(ctx, cleanupError), http_status: 401, credential_cleanup_pending: true });
|
|
305
|
+
}
|
|
306
|
+
return new CliError("unauthorized", "授权已失效,本地凭证尚未清理完成。", "请勿继续使用;先检查本地文件安全,再处理已失效的凭证,不要输出其内容。", 3, { http_status: 401, credential_cleanup_pending: true });
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
let who;
|
|
310
|
+
try {
|
|
311
|
+
who = await validate(ctx, credential, true);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (error instanceof CliError && asRecord(error.details)?.http_status === 401)
|
|
315
|
+
throw await cleanupUnauthorized(error);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
if (who.kind !== "device")
|
|
319
|
+
throw new CliError("device_token_required", "此命令只能吊销当前设备令牌。", "手动令牌请在网页管理;本地凭证未被删除。", 4);
|
|
320
|
+
const result = await request(ctx, `${prefix}/device-token`, { method: "DELETE", token: credential.token });
|
|
321
|
+
if (result.status === 401)
|
|
322
|
+
throw await cleanupUnauthorized(apiError(result));
|
|
323
|
+
expectOK(result);
|
|
324
|
+
const body = asRecord(result.body), token = asRecord(body?.token);
|
|
325
|
+
if (result.status !== 200 || body?.status !== "revoked" || !token || token.id !== who.token_id || token.user_id !== who.user_id || token.kind !== "device" || token.scope !== who.scope || !date(token.revoked_at))
|
|
326
|
+
throw invalid();
|
|
327
|
+
try {
|
|
328
|
+
await removeMatching();
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error instanceof CliError && error.code === "installation_permission_denied") {
|
|
332
|
+
throw new CliError("installation_permission_denied", "当前设备已吊销,但没有权限清除本地凭据。", "请核对本地凭据目录的清空或删除限制;不要重复吊销、自动重新授权或声称断开完成。", 4, { ...credentialTarget(ctx, error), credential_cleanup_pending: true });
|
|
333
|
+
}
|
|
334
|
+
throw new CliError("credential_cleanup_failed", "当前设备已吊销,但本地凭证尚未清理完成。", "请检查本地凭证文件安全;不要重复吊销或自动重新授权。");
|
|
335
|
+
}
|
|
336
|
+
return { status: 200, body: { status: "revoked", source: credential.source, ...who, user_message: connectionCopy.disconnected } };
|
|
337
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { apiError, asRecord, CliError, expectOK, singleSwitchMetadata, unverifiedSwitchError } from "./errors.js";
|
|
2
|
+
const capabilityActions = {
|
|
3
|
+
read_notifications: "请在网页检查“读取通知”开关及这台设备的权限。",
|
|
4
|
+
read_drafts: "请在网页检查“读取草稿”开关及这台设备的权限。",
|
|
5
|
+
delete_or_archive: "请在网页检查“删除或归档申请”开关及这台设备的读写权限;申请仍需本人在网页确认。",
|
|
6
|
+
};
|
|
7
|
+
export function expectCapabilityResult(result, capability, requestKind) {
|
|
8
|
+
if (result.status === 403) {
|
|
9
|
+
const error = apiError(result, requestKind), body = asRecord(result.body);
|
|
10
|
+
if (error.code === "legal_consent_required" || error.code === "token_scope_insufficient" || error.code === "second_confirmation_required")
|
|
11
|
+
throw error;
|
|
12
|
+
if (body && !singleSwitchMetadata(body))
|
|
13
|
+
throw unverifiedSwitchError();
|
|
14
|
+
if (body && (Object.hasOwn(body, "required_capability") || (body.code !== undefined && body.error !== undefined && body.code !== body.error))) {
|
|
15
|
+
if (error.details.required_capability !== undefined && error.details.required_capability !== capability)
|
|
16
|
+
throw unverifiedSwitchError();
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
// Older servers returned bare 403. Offer the known operation's check as
|
|
20
|
+
// guidance, not a claim that this particular switch was observed closed.
|
|
21
|
+
const nextAction = capabilityActions[capability];
|
|
22
|
+
throw new CliError(error.code, "当前操作权限不足。", nextAction, error.exitCode, { ...error.details, required_capability: capability });
|
|
23
|
+
}
|
|
24
|
+
return expectOK(result, requestKind);
|
|
25
|
+
}
|
|
26
|
+
// A known switch name is still not evidence if unrelated to this operation.
|
|
27
|
+
export function commandAcceptsSwitch(args, capability) {
|
|
28
|
+
const [name, op] = args;
|
|
29
|
+
let eligible;
|
|
30
|
+
switch (name) {
|
|
31
|
+
case "knowledge":
|
|
32
|
+
eligible = op === "add" ? ["write_knowledge", "write_knowledge_sources"] : op === "update" ? ["write_knowledge"] : ["read_knowledge", "read_drafts"];
|
|
33
|
+
break;
|
|
34
|
+
case "projects":
|
|
35
|
+
eligible = ["read_projects", "read_drafts"];
|
|
36
|
+
break;
|
|
37
|
+
case "project":
|
|
38
|
+
eligible = op === "create" || op === "update" ? ["write_project"] : ["read_projects", "read_drafts"];
|
|
39
|
+
break;
|
|
40
|
+
case "search":
|
|
41
|
+
case "activity":
|
|
42
|
+
eligible = ["read_projects", "read_knowledge"];
|
|
43
|
+
break;
|
|
44
|
+
case "progress":
|
|
45
|
+
eligible = op === "add" ? ["write_project_update"] : ["read_projects"];
|
|
46
|
+
break;
|
|
47
|
+
case "checkins":
|
|
48
|
+
eligible = ["read_projects"];
|
|
49
|
+
break;
|
|
50
|
+
case "source":
|
|
51
|
+
case "upload":
|
|
52
|
+
eligible = ["write_knowledge_sources"];
|
|
53
|
+
break;
|
|
54
|
+
case "link":
|
|
55
|
+
case "unlink":
|
|
56
|
+
eligible = ["write_knowledge"];
|
|
57
|
+
break;
|
|
58
|
+
case "advance":
|
|
59
|
+
eligible = ["write_knowledge_stage", "write_project_update"];
|
|
60
|
+
break;
|
|
61
|
+
case "cognition":
|
|
62
|
+
eligible = ["write_project_cognition"];
|
|
63
|
+
break;
|
|
64
|
+
case "stage":
|
|
65
|
+
eligible = ["write_project"];
|
|
66
|
+
break;
|
|
67
|
+
case "notifications":
|
|
68
|
+
case "pending":
|
|
69
|
+
eligible = ["read_notifications"];
|
|
70
|
+
break;
|
|
71
|
+
case "drafts":
|
|
72
|
+
eligible = ["read_drafts"];
|
|
73
|
+
break;
|
|
74
|
+
case "request":
|
|
75
|
+
eligible = ["delete_or_archive"];
|
|
76
|
+
break;
|
|
77
|
+
default: eligible = [];
|
|
78
|
+
}
|
|
79
|
+
return typeof capability === "string" && eligible.includes(capability);
|
|
80
|
+
}
|