@xinhe-oa/cli 0.0.0-stage → 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.
@@ -0,0 +1,300 @@
1
+ // 输出渲染(契约 §3.1 / §3.3)。
2
+ //
3
+ // 两条硬规矩:
4
+ //
5
+ // 1. **stdout 只放结果**;日志与进度一律 stderr(`warn` / `verbose` / `progress`)。
6
+ // 否则管道与判卷会被污染。
7
+ // 2. `--json` 的形状**稳定**:成功 `{ok,data,meta}`,失败 `{ok:false,error,meta}`,
8
+ // 失败也要有 `meta.request_id`(与服务端审计对齐)。
9
+ //
10
+ // 人类可读输出默认 `table`,`--format md|table|json` 可切换。
11
+
12
+ import { CliError } from './error.js';
13
+ import { compactJson, displayWidth, padWidth, sortKeysDeep, truncateWidth } from './util.js';
14
+ import { VERSION } from '../version.js';
15
+
16
+ /** 输出格式。 */
17
+ export const Format = Object.freeze({
18
+ Table: 'table',
19
+ Markdown: 'md',
20
+ Json: 'json',
21
+ });
22
+
23
+ export function parseFormat(raw) {
24
+ const text = String(raw ?? '').trim().toLowerCase();
25
+ if (text === 'json') return Format.Json;
26
+ if (text === 'md' || text === 'markdown') return Format.Markdown;
27
+ if (text === 'table' || text === 'text') return Format.Table;
28
+ throw CliError.usage(`--format 只能是 table / md / json,收到 ${JSON.stringify(text)}`);
29
+ }
30
+
31
+ /** 一次调用的元信息(`meta` 段)。**六个键永远都在**(判卷按形状断言)。 */
32
+ export class Meta {
33
+ constructor(init = {}) {
34
+ this.command = init.command ?? '';
35
+ this.cliVersion = init.cliVersion ?? '';
36
+ this.serverVersion = init.serverVersion ?? null;
37
+ this.requestId = init.requestId ?? null;
38
+ this.durationMs = init.durationMs ?? 0;
39
+ this.profile = init.profile ?? 'default';
40
+ }
41
+
42
+ toJson() {
43
+ return {
44
+ command: this.command,
45
+ cli_version: this.cliVersion.length > 0 ? this.cliVersion : VERSION,
46
+ server_version: this.serverVersion,
47
+ request_id: this.requestId,
48
+ duration_ms: this.durationMs,
49
+ profile: this.profile,
50
+ };
51
+ }
52
+ }
53
+
54
+ function writeLine(text) {
55
+ process.stdout.write(`${text}\n`);
56
+ }
57
+
58
+ /** 成功输出。`data` 是命令专属内容,`meta` 是这次调用的元信息。 */
59
+ export function renderSuccess(data, meta, format) {
60
+ return renderSuccessWith(data, null, meta, format);
61
+ }
62
+
63
+ /**
64
+ * 成功输出 + **调用方给的人类可读视图**。
65
+ *
66
+ * 有些命令的 `data` 是给机器读的(`me` 的身份/范围/能力、`--import --dry-run` 的完整计划),
67
+ * 通用表格渲染说不出重点。这些命令用 `human` 给一份给人看的文本;
68
+ * **`--json` 一个字都不变**(机器面稳定)。
69
+ */
70
+ export function renderSuccessWith(data, human, meta, format) {
71
+ if (format === Format.Json) {
72
+ // `sortKeysDeep`:Rust 的 serde_json 是 BTreeMap,键全按字典序(见 util.js)。
73
+ writeLine(JSON.stringify(sortKeysDeep({ ok: true, data, meta: meta.toJson() }), null, 2));
74
+ return;
75
+ }
76
+ writeLine(humanText(data, human, format === Format.Markdown));
77
+ }
78
+
79
+ /** 失败输出。**`--json` 下也走 stdout**(判卷要解析统一的失败形状),
80
+ * 但人类可读时把错误写 stderr —— 这样 `cmd > out.txt` 不会把报错混进结果。 */
81
+ export function renderFailure(error, meta, format) {
82
+ if (format === Format.Json) {
83
+ writeLine(
84
+ JSON.stringify(
85
+ sortKeysDeep({ ok: false, error: error.toErrorJson(), meta: meta.toJson() }),
86
+ null,
87
+ 2,
88
+ ),
89
+ );
90
+ return;
91
+ }
92
+ const lines = [`✗ [${error.kindLabel}] ${error.message}(${error.code})`];
93
+ // 给人看的细节优先(冲突清单这种又长又密的东西压成一行 JSON 等于没说):
94
+ // 先摆事实,再给「下一步」—— 顺序反了会让人先看到结论再看到依据。
95
+ if (error.human) {
96
+ lines.push(error.human);
97
+ } else if (error.details && !isEmptyObject(error.details) && error.details !== null) {
98
+ lines.push(` 详情:${compactJson(error.details)}`);
99
+ }
100
+ if (error.hint) lines.push(` 下一步:${error.hint}`);
101
+ if (error.requestId) lines.push(` request_id:${error.requestId}`);
102
+ process.stderr.write(`${lines.join('\n')}\n`);
103
+ }
104
+
105
+ function isEmptyObject(value) {
106
+ return (
107
+ typeof value === 'object' &&
108
+ value !== null &&
109
+ !Array.isArray(value) &&
110
+ Object.keys(value).length === 0
111
+ );
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // 日志与进度(一律 stderr)
116
+ // ---------------------------------------------------------------------------
117
+ /** 告警:**不受 `--quiet` 影响**(那属于"结果的一部分")。 */
118
+ export function warn(message) {
119
+ process.stderr.write(`· ${message}\n`);
120
+ }
121
+
122
+ export function verbose(enabled, message) {
123
+ if (enabled) process.stderr.write(`· ${message}\n`);
124
+ }
125
+
126
+ /**
127
+ * 进度行(长任务专用):`--quiet` 时静默。
128
+ *
129
+ * 与 `verbose` 的区别是**默认开着**:一次 5 分钟的导入,中途一行输出都没有,
130
+ * 用户只会以为进程挂了。告警(字段建失败、重试、降并发)走 `warn`。
131
+ */
132
+ export function progress(quiet, message) {
133
+ if (!quiet) process.stderr.write(`${message}\n`);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // 人类可读渲染
138
+ // ---------------------------------------------------------------------------
139
+ /** table / md 下到底打印哪段文本:调用方给了 `human` 就用它,否则走通用渲染。 */
140
+ function humanText(data, human, markdown) {
141
+ if (typeof human === 'string' && human.trim().length > 0) return human;
142
+ return renderHuman(data, markdown);
143
+ }
144
+
145
+ /**
146
+ * 人类可读渲染:能识别的形状渲染成表格,其余退回 JSON。
147
+ *
148
+ * 识别三种形状(覆盖全部命令的返回):
149
+ * - `{"<name>s": [ {...}, ... ]}`(列表:bases / tables / fields / records / …)
150
+ * - 单条对象
151
+ * - 标量/其它 → JSON
152
+ */
153
+ export function renderHuman(data, markdown) {
154
+ const list = listOf(data);
155
+ if (list) {
156
+ const { name, rows } = list;
157
+ let out = renderRows(name, rows, markdown);
158
+ const extras = extraScalars(data, name);
159
+ if (extras.length > 0) {
160
+ out += `${extras.map(([key, value]) => `${key}=${value}`).join(' ')}\n`;
161
+ }
162
+ return out;
163
+ }
164
+ if (isPlainObject(data)) {
165
+ // 单条对象:字段-值两列。
166
+ const rows = Object.entries(data)
167
+ .filter(([, value]) => value !== null && value !== undefined)
168
+ .map(([key, value]) => [key, scalar(value)]);
169
+ if (rows.length > 0 && rows.length <= 40) return renderPairs(rows, markdown);
170
+ }
171
+ if (data === null || data === undefined) return '';
172
+ return JSON.stringify(data, null, 2);
173
+ }
174
+
175
+ function isPlainObject(value) {
176
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
177
+ }
178
+
179
+ /**
180
+ * 找 `{"<name>s":[...]}` 这种列表形状(**空列表也算**:`(bases:0 条)`
181
+ * 比把 `{"bases":[]}` 原样吐出来有用得多)。
182
+ */
183
+ function listOf(data) {
184
+ if (!isPlainObject(data)) return null;
185
+ for (const [key, value] of Object.entries(data)) {
186
+ if (Array.isArray(value) && key.endsWith('s') && value.every(isPlainObject)) {
187
+ return { name: key, rows: value };
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /** 列表之外还有哪些顶层标量(`total` / `has_more` / `next_seq` …)——
194
+ * 只渲染表格会把它们丢掉,而它们常常正是"下一页怎么翻"的答案。 */
195
+ function extraScalars(data, listKey) {
196
+ if (!isPlainObject(data)) return [];
197
+ return Object.entries(data)
198
+ .filter(
199
+ ([key, value]) =>
200
+ key !== listKey &&
201
+ value !== null &&
202
+ value !== undefined &&
203
+ !Array.isArray(value) &&
204
+ !isPlainObject(value),
205
+ )
206
+ .map(([key, value]) => [key, scalar(value)]);
207
+ }
208
+
209
+ function renderRows(name, rows, markdown) {
210
+ if (rows.length === 0) return `(${name}:0 条 —— 这里现在什么都没有)`;
211
+ // 列 = 各行的键并集(保持首次出现顺序),最多 8 列。
212
+ const columns = [];
213
+ for (const row of rows) {
214
+ for (const key of Object.keys(row)) {
215
+ if (!columns.includes(key) && columns.length < 8) columns.push(key);
216
+ }
217
+ }
218
+ const body = rows.slice(0, 200).map((row) =>
219
+ columns.map((column) =>
220
+ Object.prototype.hasOwnProperty.call(row, column) ? scalar(row[column]) : '',
221
+ ),
222
+ );
223
+ let out = renderTable(columns, body, markdown);
224
+ out += `\n${
225
+ rows.length > 200
226
+ ? `(${name}:共 ${rows.length} 条,这里显示前 200 条;完整数据用 --json)`
227
+ : `(${name}:${rows.length} 条)`
228
+ }\n`;
229
+ const first = rows[0];
230
+ if (isPlainObject(first) && typeof first.id === 'string') {
231
+ out += `提示:第一条的 id = ${first.id}(后续命令可以直接用它)\n`;
232
+ }
233
+ return out;
234
+ }
235
+
236
+ function renderPairs(rows, markdown) {
237
+ const columns = ['字段', '值'];
238
+ const body = rows.map(([key, value]) => [key, value]);
239
+ return renderTable(columns, body, markdown);
240
+ }
241
+
242
+ /** 不带外部依赖的表格渲染。 */
243
+ export function renderTable(columns, rows, markdown) {
244
+ const widths = columns.map((column, index) => {
245
+ let width = displayWidth(column);
246
+ for (const row of rows) {
247
+ const cell = row[index];
248
+ if (cell !== undefined) width = Math.max(width, displayWidth(truncateWidth(cell, 48)));
249
+ }
250
+ return width;
251
+ });
252
+
253
+ let out = '';
254
+ if (markdown) {
255
+ out += `| ${columns.map((column, i) => padWidth(column, widths[i])).join(' | ')} |\n`;
256
+ out += `|${widths.map((width) => '-'.repeat(width + 2)).join('|')}|\n`;
257
+ for (const row of rows) {
258
+ out += `| ${columns
259
+ .map((_, index) => padWidth(truncateWidth(row[index] ?? '', 48), widths[index]))
260
+ .join(' | ')} |\n`;
261
+ }
262
+ return out;
263
+ }
264
+
265
+ out += `${columns.map((column, i) => padWidth(column, widths[i])).join(' ')}\n`;
266
+ out += `${widths.map((width) => '-'.repeat(width)).join(' ')}\n`;
267
+ for (const row of rows) {
268
+ out += `${columns
269
+ .map((_, index) => padWidth(truncateWidth(row[index] ?? '', 48), widths[index]))
270
+ .join(' ')}\n`;
271
+ }
272
+ return out.replace(/\s+$/, '');
273
+ }
274
+
275
+ /** 值的单行表示:标量直接给,数组/对象压成一行 JSON。 */
276
+ function scalar(value) {
277
+ if (value === null || value === undefined) return '';
278
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
279
+ if (typeof value === 'number') return numberToString(value);
280
+ if (typeof value === 'string') return value.replace(/\n/g, ' ');
281
+ if (Array.isArray(value)) {
282
+ if (value.length === 0) return '—';
283
+ if (value.every((item) => typeof item === 'string' || typeof item === 'number')) {
284
+ return value.map((item) => (typeof item === 'string' ? item : numberToString(item))).join(', ');
285
+ }
286
+ return `${value.length} 项`;
287
+ }
288
+ if (isPlainObject(value)) {
289
+ if (typeof value.name === 'string') return value.name;
290
+ if (typeof value.id === 'string') return value.id;
291
+ return compactJson(value);
292
+ }
293
+ return String(value);
294
+ }
295
+
296
+ /** 数字渲染:整数值不要打印成 `1.0`(Rust 的 `Number::to_string` 也是这么做的)。 */
297
+ function numberToString(value) {
298
+ if (Number.isInteger(value) && Math.abs(value) < 1e21) return String(value);
299
+ return String(value);
300
+ }
@@ -0,0 +1,302 @@
1
+ // 运行时:一次命令调用需要的全部上下文(profile / 服务端地址 / 凭据 / HTTP)。
2
+ //
3
+ // 这一层把"用户敲的那条命令"翻译成"要发给哪个后端、带哪枚令牌、要不要先续期",
4
+ // 并统一落实两条纪律:
5
+ //
6
+ // - **高风险写操作必须 `--yes`**(契约 §3.4):没有 `--yes` 就本地退出码 2,不发请求。
7
+ // - **`--dry-run` 只打印将发送的请求**(契约 §2.1):一行都不落。
8
+
9
+ import fs from 'node:fs';
10
+
11
+ import { CliError, Kind, loginHint } from './error.js';
12
+ import { CredentialStore, ConfigStore, Paths, hasAccess, hasRefresh, toCredentialJson } from './config.js';
13
+ import { Http, Auth, ProxyPlan, USER_AGENT, proxyPlan, requestSpec } from './http.js';
14
+ import { authedSend, authedUpload, ensureFresh } from './auth.js';
15
+ import { Format, Meta, parseFormat, renderSuccess, renderSuccessWith } from './output.js';
16
+ import { newDeviceId, newIdempotencyKey, rustIoErrorText } from './util.js';
17
+ import { VERSION } from '../version.js';
18
+
19
+ export class Runtime {
20
+ /**
21
+ * @param {object} globals 全局旗标(`parseArgv` 的输出)
22
+ * @param {string} command 命令标签(`meta.command`,如 `bitable base --list`)
23
+ */
24
+ constructor(globals, command) {
25
+ this.paths = Paths.resolve();
26
+ this.config = ConfigStore.load(this.paths);
27
+ this.credentials = CredentialStore.load(this.paths);
28
+ const profileOverride = globals.profile && globals.profile.trim().length > 0 ? globals.profile : null;
29
+ this.profile = profileOverride ?? this.config.currentName();
30
+ this.baseUrl = this.config.effectiveBaseUrl(globals.baseUrl, this.profile);
31
+ this.proxy = this.config.effectiveProxy();
32
+ this.format = globals.format ? parseFormat(globals.format) : globals.json ? Format.Json : Format.Table;
33
+ this.dryRun = globals.dryRun === true;
34
+ this.yes = globals.yes === true;
35
+ this.verbose = globals.verbose === true;
36
+ this.quiet = globals.quiet === true;
37
+ this.timeoutMs = globals.timeout === null || globals.timeout === undefined
38
+ ? null
39
+ : Number(globals.timeout) * 1000;
40
+ this.command = command;
41
+ this.startedAt = Date.now();
42
+ /** `me --json` 之类要按 markdown 渲染人类面时用。 */
43
+ this.markdown = this.format === Format.Markdown;
44
+ }
45
+
46
+ meta(requestId = null, serverVersion = null) {
47
+ return new Meta({
48
+ command: this.command,
49
+ cliVersion: VERSION,
50
+ serverVersion: serverVersion ?? null,
51
+ requestId: requestId ?? null,
52
+ durationMs: Date.now() - this.startedAt,
53
+ profile: this.profile,
54
+ });
55
+ }
56
+
57
+ /** 用给定的 request_id / server_version 收尾输出。 */
58
+ finish(data, requestId = null, serverVersion = null) {
59
+ renderSuccess(data, this.meta(requestId, serverVersion), this.format);
60
+ }
61
+
62
+ finishFrom(response, data) {
63
+ this.finish(data, response?.request_id ?? null, response?.server_version ?? null);
64
+ }
65
+
66
+ /** 收尾:`--json` 走 `data`(机器面稳定),table / md 用调用方给的人类可读视图。 */
67
+ finishWithHuman(data, human) {
68
+ const text = typeof human === 'function' ? human(this.markdown) : human;
69
+ renderSuccessWith(data, text, this.meta(), this.format);
70
+ }
71
+
72
+ finishFromWithHuman(response, data, human) {
73
+ const text = typeof human === 'function' ? human(this.markdown) : human;
74
+ renderSuccessWith(data, text, this.meta(response?.request_id ?? null, response?.server_version ?? null), this.format);
75
+ }
76
+
77
+ /** 高风险写操作的 `--yes` 闸门。 */
78
+ requireYes(what, example) {
79
+ return gateHighRisk(this.yes, what, example);
80
+ }
81
+
82
+ /** 需要后端地址的命令在这里统一报错(配置缺失 ≠ 网络故障)。 */
83
+ requireBaseUrl() {
84
+ if (!this.baseUrl) {
85
+ throw CliError.usage('还没有配置后端地址').withHint(
86
+ '先指一次后端:xinhe-oa-cli config --set base_url=http://127.0.0.1:8102',
87
+ );
88
+ }
89
+ return this.baseUrl;
90
+ }
91
+
92
+ /**
93
+ * 已登录上下文:没有凭据就是退出码 3 + 登录 hint。
94
+ *
95
+ * 返回的 `session.credential` 在续期时会被**原地替换** —— 调用方在请求结束后
96
+ * 应当 `runtime.persistCredentials(session)`,否则轮换后的 refresh 就丢了。
97
+ */
98
+ authed() {
99
+ const baseUrl = this.requireBaseUrl();
100
+ const stored = this.credentials.get(this.profile);
101
+ if (!stored || (!hasRefresh(stored) && !hasAccess(stored))) {
102
+ const phone = this.config.profile(this.profile).phone ?? null;
103
+ throw new CliError(Kind.Auth, 'auth.missing_token', '本机还没有登录凭据').withHint(
104
+ loginHint(phone),
105
+ );
106
+ }
107
+ const http = new Http(baseUrl, this.proxy, stored.device_id ?? '', this.timeoutMs);
108
+ return {
109
+ profile: this.profile,
110
+ http,
111
+ credential: stored,
112
+ store: this.credentials,
113
+ verbose: this.verbose,
114
+ runtime: this,
115
+ };
116
+ }
117
+
118
+ /** 把(可能已经轮换过的)凭据落盘。 */
119
+ persistCredentials(session) {
120
+ this.credentials.put(this.profile, toCredentialJson(session.credential));
121
+ }
122
+
123
+ /** 不需要凭据的 HTTP(登录流程、健康检查)。 */
124
+ anonymousHttp() {
125
+ const baseUrl = this.requireBaseUrl();
126
+ return new Http(baseUrl, this.proxy, this.deviceIdForLogin(), this.timeoutMs);
127
+ }
128
+
129
+ /** 登录前先确保 device_id 存在(服务端把 refresh 绑在它上面)。 */
130
+ deviceIdForLogin() {
131
+ const stored = this.credentials.get(this.profile);
132
+ if (stored && typeof stored.device_id === 'string' && stored.device_id.length > 0) {
133
+ return stored.device_id;
134
+ }
135
+ return newDeviceId();
136
+ }
137
+
138
+ /** 生成 device_id 并立刻落盘(`login` 的第一步就是它)。 */
139
+ ensureDeviceId() {
140
+ const stored = this.credentials.get(this.profile);
141
+ if (stored && typeof stored.device_id === 'string' && stored.device_id.length > 0) {
142
+ return stored.device_id;
143
+ }
144
+ const deviceId = newDeviceId();
145
+ this.credentials.put(this.profile, {
146
+ base_url: this.baseUrl ?? undefined,
147
+ device_id: deviceId,
148
+ tokens: {},
149
+ });
150
+ if (this.verbose) process.stderr.write(`· 生成设备指纹 ${deviceId} 并落盘\n`);
151
+ return deviceId;
152
+ }
153
+
154
+ /** 已认证请求 + 自动续期(凭据轮换后**立即落盘**)。 */
155
+ async sendAuthed(method, path, spec) {
156
+ const session = this.authed();
157
+ try {
158
+ const response = await authedSend(session, method, path, spec, this.command);
159
+ this.persistCredentials(session);
160
+ return response;
161
+ } catch (err) {
162
+ // 续期可能已经改了凭据;失败也要把它落盘(刷新过就是刷新过了)。
163
+ this.persistCredentials(session);
164
+ throw err;
165
+ }
166
+ }
167
+
168
+ /** 匿名请求(登录)。 */
169
+ async sendAnonymous(method, path, spec) {
170
+ const http = this.anonymousHttp();
171
+ return http.send(method, path, Auth.none(), spec, this.command);
172
+ }
173
+
174
+ /** multipart 上传(附件)。 */
175
+ async sendUpload(path, form, idempotencyKey = null) {
176
+ const session = this.authed();
177
+ try {
178
+ const response = await authedUpload(session, path, form, this.command, idempotencyKey);
179
+ this.persistCredentials(session);
180
+ return response;
181
+ } finally {
182
+ // 上传也可能触发续期:无论成败都把凭据落盘。
183
+ this.persistCredentials(session);
184
+ }
185
+ }
186
+
187
+ /** 先续期(有些命令要在本地算完再发请求,不能等到 send 那一刻)。 */
188
+ async ensureFreshToken() {
189
+ const session = this.authed();
190
+ await ensureFresh(session, this.verbose);
191
+ this.persistCredentials(session);
192
+ }
193
+
194
+ /** `--dry-run`:把将发送的请求打印出来(**不发**)。 */
195
+ dryRunReport(method, path, spec) {
196
+ const data = {
197
+ dry_run: true,
198
+ request: this.dryRunRequest(method, path, spec),
199
+ note: '--dry-run:没有发出任何请求,也没有写任何东西',
200
+ };
201
+ renderSuccess(data, this.meta(), this.format);
202
+ }
203
+
204
+ /** `--dry-run` 的单个请求描述(多请求的命令自己拼清单)。 */
205
+ dryRunRequest(method, path, spec) {
206
+ const base = this.baseUrl ?? '<未配置>';
207
+ const stored = this.credentials.get(this.profile);
208
+ const deviceId = stored?.device_id && stored.device_id.length > 0 ? stored.device_id : '<未生成>';
209
+ const headers = {
210
+ 'User-Agent': USER_AGENT,
211
+ 'X-CLI-Version': VERSION,
212
+ 'X-Device-Id': deviceId,
213
+ 'X-Request-Id': '<每请求生成>',
214
+ 'Idempotency-Key': spec.idempotencyKey ?? null,
215
+ Authorization: stored ? 'Bearer <从凭据文件读,不打印>' : '<未登录>',
216
+ };
217
+ // `multipart` **永远在**(不是 multipart 请求时是 `null`):与 Rust 的
218
+ // `dry_run_request` 逐字对齐 —— 少了这个键,`--dry-run` 的对拍就会有差异。
219
+ const request = {
220
+ method,
221
+ url: `${base.replace(/\/+$/, '')}${path}`,
222
+ headers,
223
+ query: (spec.query ?? []).map(([key, value]) => ({ [key]: value })),
224
+ body: spec.body ?? null,
225
+ multipart: spec.multipart
226
+ ? {
227
+ fields: spec.multipart.fields.map(([key]) => key),
228
+ file: spec.multipart.file
229
+ ? {
230
+ field: spec.multipart.file[0],
231
+ name: spec.multipart.file[1],
232
+ bytes: Buffer.byteLength(spec.multipart.file[2]),
233
+ }
234
+ : null,
235
+ }
236
+ : null,
237
+ };
238
+ return request;
239
+ }
240
+
241
+ /** 代理策略的一句话描述(`doctor` / `config --show` 用)。 */
242
+ proxyDescription() {
243
+ const base = this.baseUrl ?? '';
244
+ const mode = this.proxy;
245
+ if (base.length === 0) return `${mode}(还没配 base_url)`;
246
+ const plan = proxyPlan(base, mode);
247
+ const explanation =
248
+ plan === ProxyPlan.DirectLoopback
249
+ ? '直连(本机/内网地址,忽略环境代理)'
250
+ : plan === ProxyPlan.System
251
+ ? '尊重环境代理(含 NO_PROXY)'
252
+ : '直连(配置里指定 proxy=none)';
253
+ return `${mode}:${explanation}`;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * 高风险写操作的 `--yes` 闸门(纯函数:`--yes` 与"该不该问"都不碰网络)。
259
+ *
260
+ * 契约 §3.4:`high-risk-write` 没有 `--yes` 就是**本地**退出码 2,一个请求都不发。
261
+ */
262
+ export function gateHighRisk(yes, what, example) {
263
+ if (yes) return;
264
+ throw CliError.usage(`${what} 是高风险写操作,必须显式确认`).withHint(
265
+ `确认后重跑:${example} --yes`,
266
+ );
267
+ }
268
+
269
+ /**
270
+ * 服务端 `{"data": ...}` 拆包。
271
+ *
272
+ * **键在就是它**(哪怕值是 `null`):`{"data": null}` 要回 `null`,
273
+ * 不能因为"值是 null"就退回整个信封 —— 那会让调用方看到 `{"data": null}`
274
+ * 这种"多一层"的形状,与 Rust 的 `data_of` 不一致。
275
+ * 没有 `data` 键才原样返回整个 body。
276
+ */
277
+ export function dataOf(response) {
278
+ const body = response?.body;
279
+ if (body !== null && typeof body === 'object' && Object.prototype.hasOwnProperty.call(body, 'data')) {
280
+ return body.data;
281
+ }
282
+ return body ?? {};
283
+ }
284
+
285
+ /** 生成幂等键(写操作自动带;`--idempotency-key` 可覆盖)。 */
286
+ export function idempotencyKey(command, override) {
287
+ if (override !== null && override !== undefined && String(override).trim().length > 0) {
288
+ return String(override);
289
+ }
290
+ return newIdempotencyKey(command);
291
+ }
292
+
293
+ /** 读本地文件(附件上传、manifest 安装)。 */
294
+ export function readFileBytes(path) {
295
+ try {
296
+ return fs.readFileSync(path);
297
+ } catch (err) {
298
+ throw CliError.usage(`读不了文件 ${path}:${rustIoErrorText(err)}`);
299
+ }
300
+ }
301
+
302
+ export { requestSpec };