@qmuse/qmuse-cli 1.0.9-dev.4 → 1.0.10-dev.1

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/client.js CHANGED
@@ -1,6 +1,27 @@
1
1
  import { currentOperationId, recordHttpResult } from './operation-log.js';
2
+ import { normalizeRoute, recordEvent, telemetryCommand, telemetryEnvironment, telemetryRequestHeaders } from './telemetry.js';
3
+ import { classifyNetworkError, httpStatusHint, parseRetryAfterMs, sanitizeMessage } from './errors.js';
4
+ /** T01.02/03:每次业务 HTTP 请求记录受控来源事件 + 服务端 traceId 对账键。 */
5
+ function recordTelemetryHttp(input) {
6
+ const headerTrace = input.response?.headers.get('x-trace-id') || input.response?.headers.get('trace-id') || undefined;
7
+ recordEvent({
8
+ kind: 'cli.http.request',
9
+ method: input.method,
10
+ route: normalizeRoute(input.route),
11
+ httpStatus: input.response?.status ?? 0,
12
+ durationMs: Date.now() - input.startedAt,
13
+ errorCode: input.errorCode,
14
+ result: input.networkError || input.response?.ok === false || input.businessFailed ? 'failed' : 'received',
15
+ operationId: currentOperationId(),
16
+ command: telemetryCommand(),
17
+ environment: telemetryEnvironment(),
18
+ trace: input.trace ?? headerTrace ?? undefined,
19
+ });
20
+ }
2
21
  import { apiOrigin, loadConfig } from './config.js';
3
22
  import { QMUSE_CLI_DISTRIBUTION, QMUSE_CLI_NAME, QMUSE_CLI_VERSION, } from './version.js';
23
+ /** 业务请求默认超时:避免服务端无响应时 CLI 无限挂起。长任务(上传/SSE)调用方显式放大。 */
24
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
4
25
  /** 拼接完整请求地址。 */
5
26
  export function buildUrl(cfg, path) {
6
27
  const base = cfg.baseUrl.replace(/\/+$/, '');
@@ -36,6 +57,8 @@ export function authHeaders(cfg) {
36
57
  const operationId = currentOperationId();
37
58
  if (operationId)
38
59
  headers['x-qmuse-operation-id'] = operationId;
60
+ // T01.02:受控命令链与进程级 invocation 关联(服务端按未知头忽略,兼容旧网关)。
61
+ Object.assign(headers, telemetryRequestHeaders());
39
62
  const sid = (cfg.sessionId || '').trim();
40
63
  if (sid && credentialMatchesTarget(cfg)) {
41
64
  headers['x-session-id'] = sid;
@@ -67,12 +90,27 @@ function extractData(json) {
67
90
  // 非标准包装:按 HTTP 状态判定,原始体作为 data 返回
68
91
  return { ok: false, data: json };
69
92
  }
93
+ /**
94
+ * 区分 CallOptions 与 QmuseCliConfig:
95
+ * 带 baseUrl 的对象默认是配置;若同时显式带了 timeoutMs,则按「配置 + 选项」混合处理,
96
+ * 让 `apiCall(..., { ...cfg, timeoutMs })` 这种写法也能生效。
97
+ */
98
+ function isCallOptions(value) {
99
+ if (!value || typeof value !== 'object')
100
+ return false;
101
+ const record = value;
102
+ if (!('baseUrl' in record))
103
+ return true;
104
+ return ['timeoutMs', 'anonymous', 'signal', 'redirect'].some(key => key in record);
105
+ }
70
106
  /** 发起一次 musepub 调用。 */
71
107
  export async function apiCall(method, path, body, cfgOverrideOrOpts) {
72
- const opts = cfgOverrideOrOpts && typeof cfgOverrideOrOpts === 'object' && !('baseUrl' in cfgOverrideOrOpts)
108
+ const opts = isCallOptions(cfgOverrideOrOpts)
73
109
  ? cfgOverrideOrOpts
74
110
  : { cfg: cfgOverrideOrOpts };
75
- const cfg = opts.cfg ?? loadConfig();
111
+ const cfg = opts.cfg ?? (cfgOverrideOrOpts && typeof cfgOverrideOrOpts === 'object' && 'baseUrl' in cfgOverrideOrOpts
112
+ ? cfgOverrideOrOpts
113
+ : loadConfig());
76
114
  const url = buildUrl(cfg, path);
77
115
  const headers = opts.anonymous
78
116
  ? {
@@ -81,13 +119,17 @@ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
81
119
  'x-qmuse-client': QMUSE_CLI_NAME,
82
120
  'x-qmuse-client-version': QMUSE_CLI_VERSION,
83
121
  'x-qmuse-client-distribution': QMUSE_CLI_DISTRIBUTION,
122
+ ...telemetryRequestHeaders(),
84
123
  }
85
124
  : authHeaders(cfg);
86
125
  if (method === 'POST' && !headers['Referer']) {
87
126
  Object.assign(headers, requestSourceHeaders(cfg));
88
127
  }
128
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
129
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
130
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
89
131
  const init = { method, headers,
90
- ...(opts.signal ? { signal: opts.signal } : {}),
132
+ signal,
91
133
  ...(opts.redirect ? { redirect: opts.redirect } : {}),
92
134
  };
93
135
  if (body !== undefined && method !== 'GET') {
@@ -99,15 +141,20 @@ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
99
141
  res = await fetch(url, init);
100
142
  }
101
143
  catch (err) {
144
+ // 调用方主动中止(如 Ctrl-C)不算网络错误,保留原语义给上层判断。
145
+ if (opts.signal?.aborted === true)
146
+ throw err;
147
+ const classified = classifyNetworkError(err, timeoutMs);
148
+ recordTelemetryHttp({ method, route: path, startedAt, networkError: true, errorCode: classified.code });
102
149
  recordHttpResult({ method, route: path, startedAt, networkError: true });
103
- const msg = err instanceof Error ? err.message : String(err);
104
150
  return {
105
151
  ok: false,
106
152
  status: 0,
107
153
  raw: null,
108
154
  setCookie: [],
109
- errorCode: 'NETWORK_ERROR',
110
- errorMessage: `请求失败 ${url}: ${msg}`,
155
+ errorCode: classified.code,
156
+ errorMessage: `${classified.message}(${buildUrl(cfg, '')})`,
157
+ ...(classified.hint ? { hint: classified.hint } : {}),
111
158
  };
112
159
  }
113
160
  let json = null;
@@ -124,18 +171,43 @@ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
124
171
  }
125
172
  }
126
173
  recordHttpResult({ method, route: path, startedAt, response: res, payload: json });
174
+ recordTelemetryHttp({
175
+ method, route: path, startedAt, response: res,
176
+ errorCode: typeof json?.errorCode === 'string'
177
+ ? json.errorCode : undefined,
178
+ trace: typeof json?.traceId === 'string'
179
+ ? json.traceId : undefined,
180
+ // HTTP 200 但包装层 success=false 也计为业务失败(T01.18 失败口径)。
181
+ businessFailed: json?.success === false,
182
+ });
127
183
  const setCookie = readSetCookie(res);
128
184
  const extracted = extractData(json);
185
+ const traceId = (typeof json?.traceId === 'string'
186
+ ? json.traceId
187
+ : res.headers.get('x-trace-id') ?? undefined);
188
+ const retryAfterMs = res.status === 429 ? parseRetryAfterMs(res.headers.get('retry-after')) : undefined;
189
+ const hint = buildHttpHint(res.status, extracted.errorCode, extracted.ok);
129
190
  return {
130
191
  ok: extracted.ok,
131
192
  data: extracted.data,
132
193
  errorCode: extracted.errorCode,
133
- errorMessage: extracted.errorMessage,
194
+ errorMessage: extracted.errorMessage === undefined ? undefined : sanitizeMessage(extracted.errorMessage),
134
195
  raw: json,
135
196
  status: res.status,
136
197
  setCookie,
198
+ ...(hint ? { hint } : {}),
199
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
200
+ ...(traceId ? { traceId } : {}),
137
201
  };
138
202
  }
203
+ /** HTTP 状态 + 服务端错误码 → 可执行建议(限流单独给等待时间)。 */
204
+ function buildHttpHint(status, errorCode, ok) {
205
+ if (ok)
206
+ return undefined;
207
+ if (status === 429)
208
+ return '触发限流:请按 retryAfterMs 等待后重试';
209
+ return httpStatusHint(status) ?? (errorCode ? undefined : undefined);
210
+ }
139
211
  /** 从响应读取所有 Set-Cookie 头(Node undici Headers.getSetCookie)。 */
140
212
  function readSetCookie(res) {
141
213
  const h = res.headers;
package/dist/errors.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 统一错误协议与输出脱敏(对齐业界 CLI 最佳实践)。
3
+ *
4
+ * - 结构化错误:{ success:false, error:{ code, message, hint, traceId?, details? } }
5
+ * 人读模式:`✖ message` + 缩进 `hint`,退出码由调用方决定。
6
+ * - 脱敏:服务端回显可能带 ANSI 转义、URL 账密、Bearer token、key=value 形式的密钥,
7
+ * 输出与埋点前统一清洗,避免凭证经 stdout 泄漏。
8
+ */
9
+ const ANSI_OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
10
+ const ANSI_CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
11
+ const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
12
+ /** 清洗服务端/第三方回显的文本:去控制字符、脱敏凭证、限长。 */
13
+ export function sanitizeMessage(value, limit = 4000) {
14
+ const text = typeof value === 'string' ? value : value == null ? '' : String(value);
15
+ const cleaned = text
16
+ .replace(ANSI_OSC, '')
17
+ .replace(ANSI_CSI, '')
18
+ .replace(CONTROL_CHARS, '')
19
+ // URL 账密:https://user:pass@host → https://[REDACTED]@host
20
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/giu, '$1[REDACTED]@')
21
+ // Bearer / Basic 认证
22
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/giu, '$1 [REDACTED]')
23
+ // key=value / key: value 形式的密钥,以及 Cookie 内容
24
+ .replace(/((?:[\w-]*(?:key|token|secret|password|passwd|credential|session|signature|cookie)[\w-]*)\s*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/giu, '$1[REDACTED]')
25
+ // muse_user=<sid> 这类裸 cookie 片段
26
+ .replace(/\b(muse_user|muse_session)=[^\s;,"']+/giu, '$1=[REDACTED]');
27
+ return cleaned.length > limit ? `${cleaned.slice(0, limit)}…` : cleaned;
28
+ }
29
+ const NETWORK_HINTS = {
30
+ REQUEST_TIMEOUT: '服务端响应超时:请稍后重试;持续超时请检查网络或联系平台',
31
+ ECONNREFUSED: '无法连接到 API 服务:请确认地址正确、服务已启动(本地开发请用 --env local)',
32
+ ENOTFOUND: '域名解析失败:请检查网络与 --env 配置',
33
+ EAI_AGAIN: '域名解析暂时失败:请检查网络后重试',
34
+ ECONNRESET: '连接被重置:请重试;如持续出现请联系平台',
35
+ UND_ERR_CONNECT_TIMEOUT: '连接超时:请检查网络后重试',
36
+ };
37
+ /** 网络层错误 → CLI 错误码与建议。 */
38
+ export function classifyNetworkError(error, timeoutMs) {
39
+ const err = error;
40
+ const code = err?.cause?.code || err?.code || '';
41
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError' && /abort/i.test(String(err?.message ?? ''))) {
42
+ return {
43
+ code: 'REQUEST_TIMEOUT',
44
+ message: timeoutMs ? `请求在 ${timeoutMs}ms 内未完成` : '请求超时',
45
+ hint: NETWORK_HINTS.REQUEST_TIMEOUT,
46
+ };
47
+ }
48
+ if (code && NETWORK_HINTS[code]) {
49
+ return { code, message: `网络请求失败:${err?.message ?? code}`, hint: NETWORK_HINTS[code] };
50
+ }
51
+ return {
52
+ code: 'NETWORK_ERROR',
53
+ message: `网络请求失败:${err?.message ?? String(error)}`,
54
+ hint: '请检查网络连接后重试',
55
+ };
56
+ }
57
+ /** 服务端错误码 → 建议(HTTP 状态不可用时兜底,覆盖面按实际契约逐条补)。 */
58
+ export function errorCodeHint(code) {
59
+ switch (String(code ?? '').toUpperCase()) {
60
+ case 'NOT_FOUND':
61
+ return '资源不存在:请核对 appId/表单/版本 ID;也可能是该环境尚未部署对应接口';
62
+ case 'FORBIDDEN':
63
+ case 'UNAUTHORIZED':
64
+ return '无权限或登录态失效:请重新 qmuse login,或确认当前账号对目标空间/应用的权限';
65
+ case 'PERMISSION_DENIED':
66
+ return '权限不足:请确认账号在该空间/应用的角色';
67
+ case 'MODEL_REQUIRED':
68
+ return '未指定模型:加 --model <model>,或用 qmuse models 查看可用清单';
69
+ case 'INTERNAL_ERROR':
70
+ case 'INTERNALSERVERERROR':
71
+ return '服务端异常:请稍后重试;如持续出现请携带 traceId 反馈平台';
72
+ case 'APPWRITE_PROJECT_SECRET_NOT_FOUND':
73
+ return '密钥不存在或不属于当前应用:请用 qmuse secret list 核对 ID';
74
+ case 'APPWRITE_PROJECT_SECRET_DELETE_FORBIDDEN':
75
+ return '服务端只允许删除“已停用且同步完成”的密钥:先 qmuse secret disable 再 delete';
76
+ case 'CLOUD_SERVICE_LIMIT_EXCEEDED':
77
+ return '空间云服务额度已满:请释放不再使用的应用云服务或扩容';
78
+ default:
79
+ return undefined;
80
+ }
81
+ }
82
+ /** HTTP 状态 → 建议(仅在服务端没给出更具体 errorCode 时使用)。 */
83
+ export function httpStatusHint(status) {
84
+ if (status === 401)
85
+ return '登录态无效或已过期:请重新执行 qmuse login';
86
+ if (status === 403)
87
+ return '无权限:请确认当前账号对目标空间/应用的权限';
88
+ if (status === 404)
89
+ return '资源不存在:请核对 ID/路径;接口可能尚未在该环境部署';
90
+ if (status === 409)
91
+ return '状态冲突:请先同步最新状态后重试';
92
+ if (status === 429)
93
+ return '触发限流:请稍后重试';
94
+ if (status >= 500)
95
+ return '服务端异常:请稍后重试;如持续出现请携带 traceId 反馈平台';
96
+ return undefined;
97
+ }
98
+ /** 从响应头解析 Retry-After(秒数或 HTTP-date 均可)。 */
99
+ export function parseRetryAfterMs(value) {
100
+ if (!value)
101
+ return undefined;
102
+ const seconds = Number(value);
103
+ if (Number.isFinite(seconds) && seconds >= 0)
104
+ return Math.ceil(seconds * 1000);
105
+ const at = Date.parse(value);
106
+ if (!Number.isNaN(at))
107
+ return Math.max(0, at - Date.now());
108
+ return undefined;
109
+ }
110
+ /** 本地语义错误码:没有服务端错误码时,从消息里推断稳定的 code(便于脚本分支)。 */
111
+ export function inferLocalCode(message) {
112
+ const text = String(message ?? '');
113
+ if (/未知命令|unknown command/iu.test(text))
114
+ return 'UNKNOWN_COMMAND';
115
+ if (/未知 .*子命令|用法/iu.test(text))
116
+ return 'INVALID_ARGUMENTS';
117
+ if (/未登录|请先运行 `?qmuse login/iu.test(text))
118
+ return 'NOT_LOGGED_IN';
119
+ if (/超时|未完成|timeout/iu.test(text))
120
+ return 'REQUEST_TIMEOUT';
121
+ if (/网络|network/iu.test(text))
122
+ return 'NETWORK_ERROR';
123
+ if (/没有导入绑定|未找到.*绑定/iu.test(text))
124
+ return 'BINDING_MISSING';
125
+ if (/不存在|not found/iu.test(text))
126
+ return 'NOT_FOUND';
127
+ return 'UNKNOWN';
128
+ }
129
+ /** 组装结构化错误:错误码优先取服务端 errorCode,其次 HTTP_<status>,最后从消息推断。 */
130
+ export function buildCliError(input) {
131
+ const status = input.status ?? 0;
132
+ const code = (input.errorCode && input.errorCode.trim())
133
+ || (status ? `HTTP_${status}` : inferLocalCode(input.errorMessage));
134
+ const message = sanitizeMessage(input.errorMessage) || (status ? `HTTP ${status}` : '未知错误');
135
+ const hint = input.hint ?? httpStatusHint(status) ?? errorCodeHint(code);
136
+ return {
137
+ code,
138
+ message,
139
+ ...(hint ? { hint } : {}),
140
+ ...(input.traceId ? { traceId: input.traceId } : {}),
141
+ ...(input.details && Object.keys(input.details).length > 0 ? { details: input.details } : {}),
142
+ };
143
+ }
package/dist/login.js CHANGED
@@ -92,8 +92,10 @@ export async function loginBrowser(cfg, options = {}, dependencyOverrides) {
92
92
  message: `创建登录请求失败:${request.errorMessage ?? request.errorCode ?? `HTTP ${request.status}`}`,
93
93
  };
94
94
  }
95
+ // 渠道标记:未注册用户首次登录时记录来源为 CLI 侧(chInfo 透传到注册/授权环节)。
95
96
  const authUrl = new URL('/cli-auth', cfg.webBaseUrl);
96
97
  authUrl.searchParams.set('request', token);
98
+ authUrl.searchParams.set('chInfo', 'ch_qmuse_active__qmuse_cli');
97
99
  if (options.logRequestDetails) {
98
100
  log.dim(`登录页面:${authUrl.origin}${authUrl.pathname}`);
99
101
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 全链路埋点(T01):CLI 本地事件源与有界补报。
3
+ *
4
+ * 原则(使用规则):
5
+ * - 所有实际 CLI 请求、SSE、上传下载、异步任务与适用模型/工具调用都保留来源;
6
+ * - 上报失败不阻断业务:补报只尝试一次/次进程,队列有界,丢弃最旧事件也静默;
7
+ * - 脱敏:序列化仅接受白名单字段(T01.16),错误消息、路径、提示词、凭证永不入事件。
8
+ */
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { homedir, tmpdir } from 'node:os';
11
+ import { randomUUID } from 'node:crypto';
12
+ import path from 'node:path';
13
+ import { QMUSE_CLI_DISTRIBUTION, QMUSE_CLI_VERSION } from './version.js';
14
+ const MAX_EVENTS = 5000;
15
+ const MAX_BYTES = 4 * 1024 * 1024;
16
+ const MAX_FLUSH = 50;
17
+ const MAX_ATTEMPT = 5;
18
+ /** 简单字符串安全模式:仅闭集内的受控标识。 */
19
+ const SAFE_ID = /^[a-z0-9_.:-]{1,128}$/iu;
20
+ /** 命令链:允许多词子命令(chat send / release status),拒绝其它空白与控制符。 */
21
+ const SAFE_COMMAND = /^[a-z0-9_.:-]+(?: [a-z0-9_.:-]+)*$/iu;
22
+ const SAFE_ROUTE = /^\/api\/[a-z0-9/_-]{1,120}$/iu;
23
+ const SAFE_CODE = /^[A-Za-z][A-Za-z0-9_]{0,95}$/u;
24
+ let invocationIdCache;
25
+ /** 每个进程一个 invocationId(T01.01),与 operation-log 的 operationId 正交。 */
26
+ export function invocationId() {
27
+ if (!invocationIdCache) {
28
+ invocationIdCache = `inv_${Date.now()}_${randomUUID().replaceAll('-', '').slice(0, 24)}`;
29
+ }
30
+ return invocationIdCache;
31
+ }
32
+ export function currentCommandLabel(command) {
33
+ const [root, sub] = command;
34
+ return [root, sub && !sub.startsWith('-') ? sub : undefined].filter(Boolean).join(' ');
35
+ }
36
+ // ---- T01.02:请求来源关联(受控 command、invocation、operation、trace 由服务端缀联) ----
37
+ let commandContext;
38
+ let environmentContext;
39
+ /** main() 在解析命令后设置;客户端请求头携带受控命令链,遥测事件携带环境分组。 */
40
+ export function setTelemetryCommand(label, environment) {
41
+ commandContext = label && SAFE_COMMAND.test(label) ? label : undefined;
42
+ environmentContext = environment && SAFE_ID.test(environment) ? environment : undefined;
43
+ }
44
+ export function telemetryCommand() {
45
+ return commandContext;
46
+ }
47
+ export function telemetryEnvironment() {
48
+ return environmentContext;
49
+ }
50
+ /** 请求来源头:client/version/distribution 由 authHeaders 提供,这里补 invocation/command 关联。 */
51
+ export function telemetryRequestHeaders() {
52
+ const headers = { 'x-qmuse-invocation-id': invocationId() };
53
+ if (commandContext)
54
+ headers['x-qmuse-command'] = commandContext;
55
+ return headers;
56
+ }
57
+ function telemetryPath() {
58
+ return process.env.QMUSE_CLI_TELEMETRY_PATH
59
+ ?? process.env.QMUSE_TELEMETRY_PATH
60
+ ?? path.join(homedir(), '.qmuse-cli-telemetry.jsonl');
61
+ }
62
+ /**
63
+ * 构造事件:显式白名单序列化。未知字段直接丢弃;敏感字段永远不进入事件。
64
+ */
65
+ export function buildEvent(input) {
66
+ const event = {
67
+ schemaVersion: 1,
68
+ eventId: `evt_${randomUUID()}`,
69
+ occurredAt: new Date().toISOString(),
70
+ kind: input.kind,
71
+ invocationId: input.invocationId ?? invocationId(),
72
+ attempt: 0,
73
+ cliVersion: QMUSE_CLI_VERSION,
74
+ distribution: QMUSE_CLI_DISTRIBUTION,
75
+ };
76
+ if (input.operationId && SAFE_ID.test(input.operationId))
77
+ event.operationId = input.operationId;
78
+ if (input.command && SAFE_COMMAND.test(input.command))
79
+ event.command = input.command;
80
+ if (input.environment && SAFE_ID.test(input.environment))
81
+ event.environment = input.environment;
82
+ if (input.method === 'GET' || input.method === 'POST')
83
+ event.method = input.method;
84
+ if (input.route && SAFE_ROUTE.test(input.route))
85
+ event.route = input.route;
86
+ if (typeof input.httpStatus === 'number' && input.httpStatus >= 100 && input.httpStatus < 600)
87
+ event.httpStatus = input.httpStatus;
88
+ if (typeof input.durationMs === 'number' && Number.isFinite(input.durationMs) && input.durationMs >= 0)
89
+ event.durationMs = Math.round(input.durationMs);
90
+ if (input.result && SAFE_ID.test(input.result))
91
+ event.result = input.result;
92
+ if (input.status && SAFE_ID.test(input.status))
93
+ event.status = input.status;
94
+ if (input.stage && SAFE_ID.test(input.stage))
95
+ event.stage = input.stage;
96
+ if (input.errorCode && SAFE_CODE.test(input.errorCode))
97
+ event.errorCode = input.errorCode;
98
+ if (input.fileId && SAFE_ID.test(input.fileId))
99
+ event.fileId = input.fileId;
100
+ if (input.appType && SAFE_ID.test(input.appType))
101
+ event.appType = input.appType;
102
+ if (input.target && SAFE_ID.test(input.target))
103
+ event.target = input.target;
104
+ if (input.agent && SAFE_ID.test(input.agent))
105
+ event.agent = input.agent;
106
+ if (typeof input.bytes === 'number' && Number.isFinite(input.bytes) && input.bytes >= 0)
107
+ event.bytes = Math.round(input.bytes);
108
+ if (input.digest && /^[a-f0-9]{8,128}$/iu.test(input.digest))
109
+ event.digest = input.digest;
110
+ if (input.trace && /^[a-f0-9]{12,64}$/iu.test(input.trace))
111
+ event.trace = input.trace;
112
+ return event;
113
+ }
114
+ /** 规范化 HTTP 路由:剥离 query,保留受控 path。 */
115
+ export function normalizeRoute(pathWithQuery) {
116
+ return pathWithQuery.split('?')[0] ?? pathWithQuery;
117
+ }
118
+ function readQueue() {
119
+ const file = telemetryPath();
120
+ if (!existsSync(file))
121
+ return [];
122
+ try {
123
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
124
+ const events = [];
125
+ for (const line of lines) {
126
+ try {
127
+ const parsed = JSON.parse(line);
128
+ if (parsed?.eventId && parsed.kind && typeof parsed.attempt === 'number')
129
+ events.push(parsed);
130
+ }
131
+ catch { /* 单行损坏跳过 */ }
132
+ }
133
+ return events;
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ }
139
+ function writeQueue(events) {
140
+ const file = telemetryPath();
141
+ try {
142
+ // 有界:事件数与字节数双重上限,超限静默丢弃最旧(T01.15 有界补报)。
143
+ // 裁剪后保留一半预算作为水位,避免饱和期每事件一次全量重写(O(n²) → O(n))。
144
+ let kept = events.slice(-MAX_EVENTS);
145
+ const lines = kept.map(e => JSON.stringify(e));
146
+ const total = lines.reduce((sum, line) => sum + Buffer.byteLength(line, 'utf8') + 1, 0);
147
+ if (total > MAX_BYTES) {
148
+ const budget = Math.floor(MAX_BYTES / 2);
149
+ let used = 0, start = 0;
150
+ for (let i = 0; i < lines.length; i += 1) {
151
+ used += Buffer.byteLength(lines[i], 'utf8') + 1;
152
+ if (used > budget) {
153
+ start = i;
154
+ break;
155
+ }
156
+ }
157
+ kept = kept.slice(Math.max(1, start));
158
+ }
159
+ const body = kept.map(e => JSON.stringify(e)).join('\n');
160
+ const temporary = path.join(tmpdir(), `qmuse-tele-${process.pid}-${randomUUID()}.tmp`);
161
+ writeFileSync(temporary, body ? `${body}\n` : '', { encoding: 'utf8', mode: 0o600 });
162
+ if (existsSync(file))
163
+ unlinkSync(file);
164
+ else
165
+ mkdirSync(path.dirname(file), { recursive: true });
166
+ renameSync(temporary, file);
167
+ }
168
+ catch { /* 本地持久化失败不影响业务 */ }
169
+ }
170
+ /** 记录一条事件。本地写入失败静默;调用方永远不需要 try/catch 包裹。 */
171
+ export function recordEvent(input) {
172
+ try {
173
+ const event = buildEvent(input);
174
+ const file = telemetryPath();
175
+ try {
176
+ appendFileSync(file, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
177
+ // 单次 stat:仅在读回尺寸用于压缩判断(追加后 > 上限才全量重写)。
178
+ if (statSync(file).size > MAX_BYTES)
179
+ writeQueue(readQueue());
180
+ }
181
+ catch {
182
+ // 首次写入(目录/文件不存在)或追加失败:走全量重写路径兜底。
183
+ writeQueue([...readQueue(), event]);
184
+ }
185
+ }
186
+ catch { /* 埋点永不抛出 */ }
187
+ }
188
+ /**
189
+ * 有界补报(T01.15):一次进程只尝试一轮,最多 MAX_FLUSH 条,attempt+1;
190
+ * 超过 MAX_ATTEMPT 的丢弃。任何失败都静默且不改变业务结果。
191
+ * 404(采集端点尚未部署/网关未路由)不消耗 attempt——事件原样保留,
192
+ * 避免“端点未就绪”烧光重试预算;真正被服务端处理的失败才计数。
193
+ */
194
+ export async function flushTelemetry(cfg, send) {
195
+ if (!cfg)
196
+ return undefined;
197
+ const queue = readQueue();
198
+ const alive = queue.filter(e => e.attempt < MAX_ATTEMPT);
199
+ if (alive.length === 0) {
200
+ if (queue.length > 0)
201
+ writeQueue([]); // 超过补报上限的事件有界丢弃
202
+ return undefined;
203
+ }
204
+ const batch = alive.slice(0, MAX_FLUSH).map(e => ({ ...e, attempt: e.attempt + 1 }));
205
+ const rest = alive.slice(MAX_FLUSH);
206
+ const url = `${cfg.baseUrl.replace(/\/+$/, '')}${cfg.apiPrefix ?? ''}/api/app-import/client-events`;
207
+ try {
208
+ const response = await Promise.race([
209
+ // 服务端 SPACE WRITE 鉴权请 batch 顶层 spaceId;缺失时不发(终端会在旧amiento端点上退化为静默)。
210
+ send(url, JSON.stringify({ schemaVersion: 1, spaceId: cfg.spaceId ?? '', events: batch })),
211
+ new Promise(resolve => setTimeout(() => resolve({ ok: false }), 2500)),
212
+ ]);
213
+ if (response.ok) {
214
+ writeQueue(rest);
215
+ return { flushed: batch.length, kept: rest.length };
216
+ }
217
+ if (response.httpStatus === 404) {
218
+ // 端点缺失:不消耗 attempt,队列保持原样(未被处理,无需重试计数)。
219
+ writeQueue(queue);
220
+ return { flushed: 0, kept: queue.length };
221
+ }
222
+ writeQueue([...batch, ...rest]);
223
+ return { flushed: 0, kept: batch.length + rest.length };
224
+ }
225
+ catch {
226
+ writeQueue([...batch, ...rest]);
227
+ return { flushed: 0, kept: batch.length + rest.length };
228
+ }
229
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * CLI 命令埋点(模拟 Tracert dwcookie 协议 → 蚂蚁采集 MAU)
3
+ *
4
+ * 模式参考 muse-cli scripts/utils/tracker.js(同一作者,已验证有效):
5
+ * - POST https://collect.alipay.com/dwcookie
6
+ * - x-www-form-urlencoded: data=<24 个逗号分隔字段(仅填必填 7 项)>
7
+ * - fire-and-forget,失败不影响主流程
8
+ *
9
+ * 需要注册 SPM 位(a5400 是 QMuse Web 的 A 位):
10
+ * - B 位:CLI 入口
11
+ * - C 位:每个命令一个 click 事件
12
+ */
13
+ import { randomUUID } from 'node:crypto';
14
+ const COLLECT_URL = 'https://collect.alipay.com/dwcookie';
15
+ const QMUSE_WEB_ORIGIN = 'https://www.qmuse.cn';
16
+ const SPM_A = 'a5400'; // QMuse Web 的 SPM A 位(config/config.ts tracert.spmAPos)
17
+ const SPM_B = 'b272935'; // antlog spm apply-b 注册(demand 265100137,name=QMuse CLI,event=virtual)
18
+ /** CLI 命令的 SPM C 位(B 位已通过 antlog CLI 注册;C 位发送后由采集平台自动归入 B 位) */
19
+ const SPM_MAP = {
20
+ login: 'c480301',
21
+ whoami: 'c480302',
22
+ import: 'c480303',
23
+ pull: 'c480304',
24
+ 'import-status': 'c480305',
25
+ 'import-configure': 'c480306',
26
+ create: 'c480307',
27
+ deploy: 'c480308',
28
+ 'release-status': 'c480309',
29
+ 'release-list': 'c480310',
30
+ 'app-list': 'c480311',
31
+ 'app-info': 'c480312',
32
+ 'app-use': 'c480313',
33
+ 'app-current': 'c480314',
34
+ 'app-unlink': 'c480315',
35
+ 'app-update': 'c480316',
36
+ 'app-delete': 'c480317',
37
+ 'app-create': 'c480318',
38
+ 'chat-send': 'c480319',
39
+ 'chat-resume': 'c480320',
40
+ 'chat-state': 'c480321',
41
+ 'chat-history': 'c480322',
42
+ 'chat-cancel': 'c480323',
43
+ 'chat-clear': 'c480324',
44
+ 'sandbox-status': 'c480330',
45
+ 'sandbox-logs': 'c480331',
46
+ 'sandbox-restart': 'c480332',
47
+ 'sandbox-health': 'c480333',
48
+ 'secret-list': 'c480340',
49
+ 'secret-delete': 'c480341',
50
+ 'secret-enable': 'c480342',
51
+ 'secret-disable': 'c480343',
52
+ 'secret-sync-retry': 'c480344',
53
+ 'secret-sync-status': 'c480345',
54
+ 'db-tables': 'c480350',
55
+ 'db-records': 'c480351',
56
+ 'db-permission': 'c480352',
57
+ 'fn-list': 'c480353',
58
+ 'fn-info': 'c480354',
59
+ 'fn-retry': 'c480355',
60
+ 'model-list': 'c480360',
61
+ 'info': 'c480370',
62
+ 'account': 'c480371',
63
+ 'upgrade': 'c480380',
64
+ 'agent-skill-install': 'c480390',
65
+ 'agent-skill-status': 'c480391',
66
+ 'cloud-enable': 'c480392',
67
+ 'cloud-status': 'c480393',
68
+ 'cloud-retry': 'c480394',
69
+ 'cloud-pull-env': 'c480395',
70
+ dev: 'c480396',
71
+ };
72
+ const CLIENT_ID = randomUUID();
73
+ const BIZ_TYPE = 'qmuse';
74
+ let cachedUserId = null;
75
+ /** 从 CLI 会话获取 userId(fire-and-forget;拿不到用空串,采集平台会丢这条但主流程不受影响) */
76
+ async function fetchUserId(cfg) {
77
+ if (cachedUserId !== null)
78
+ return cachedUserId;
79
+ try {
80
+ const { whoami } = await import('./api.js');
81
+ const r = await whoami(cfg);
82
+ cachedUserId = r.data?.userId?.trim() || '';
83
+ }
84
+ catch {
85
+ cachedUserId = '';
86
+ }
87
+ return cachedUserId;
88
+ }
89
+ /** 发送 dwcookie 埋点(fire-and-forget,绝不抛出) */
90
+ export function trackCommand(command, cfg) {
91
+ const spmC = SPM_MAP[command];
92
+ if (!spmC)
93
+ return;
94
+ const spm = `${SPM_A}.${SPM_B}.${spmC}`;
95
+ const sessionId = `${CLIENT_ID}_${Date.now()}`;
96
+ void fetchUserId(cfg)
97
+ .catch(() => '')
98
+ .then(userId => {
99
+ const fields = [
100
+ 'DW-COOKIE',
101
+ sessionId,
102
+ userId,
103
+ 'clicked',
104
+ spm,
105
+ '', '', '', '', '',
106
+ '', '', '', '',
107
+ '', '', '', '', '',
108
+ '', '', '', '',
109
+ ];
110
+ // 字段 17 是 bizType
111
+ fields[17] = BIZ_TYPE;
112
+ // 字段 20-22 是屏幕参数,CLI 场景填 terminal
113
+ fields[20] = 'terminal';
114
+ fields[21] = 'terminal';
115
+ fields[22] = '1';
116
+ const data = fields.join(',');
117
+ const body = `data=${encodeURIComponent(data)}`;
118
+ void fetch(COLLECT_URL, {
119
+ method: 'POST',
120
+ headers: {
121
+ 'content-type': 'application/x-www-form-urlencoded',
122
+ referer: `${QMUSE_WEB_ORIGIN}/`,
123
+ },
124
+ body,
125
+ signal: AbortSignal.timeout(5000),
126
+ }).catch(() => {
127
+ /* 采集失败不影响业务 */
128
+ });
129
+ });
130
+ }