flower-trellis 0.6.4 → 0.6.6

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.
Files changed (54) hide show
  1. package/README.md +20 -1
  2. package/enhancements/0.6/overrides/bundles/control-plane-integrity.json +2 -0
  3. package/enhancements/0.6/overrides/bundles/intent-routing.json +2 -1
  4. package/enhancements/0.6/overrides/bundles/task-reference-contract.json +24 -0
  5. package/enhancements/0.6/overrides/conflicts.json +37 -4
  6. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/patch.json +19 -0
  7. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/task-current-content.py +39 -0
  8. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/task-current-selector.py +38 -0
  9. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/patch.json +20 -0
  10. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/task-reference-content.py +64 -0
  11. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/task-reference-selector.py +2 -0
  12. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/content.md +2 -4
  13. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/planning-contract-content.md +1 -1
  14. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/readiness-content.md +2 -2
  15. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/review-content.md +2 -2
  16. package/enhancements/0.6/scripts/decision_log.py +29 -24
  17. package/enhancements/0.6/scripts/task_progress.py +54 -13
  18. package/enhancements/0.6/scripts/untracked_flow.py +13 -5
  19. package/enhancements/MANIFEST.json +9 -2
  20. package/package.json +4 -4
  21. package/src/assets/flower_session_start.py +195 -0
  22. package/src/assets/flower_telemetry_hook.py +98 -0
  23. package/src/builtin-plugins/skill-garden/content-adapter.js +15 -1
  24. package/src/cli.js +3 -1
  25. package/src/commands/init.js +36 -1
  26. package/src/commands/plugin-remote.js +61 -14
  27. package/src/commands/plugin.js +14 -1
  28. package/src/commands/self-check.js +20 -0
  29. package/src/commands/self-update.js +38 -3
  30. package/src/commands/telemetry.js +30 -1
  31. package/src/commands/uninstall.js +20 -0
  32. package/src/commands/update-check.js +19 -0
  33. package/src/commands/update.js +52 -9
  34. package/src/commands/worktree.js +114 -0
  35. package/src/lib/cli-args.js +10 -0
  36. package/src/lib/developer.js +3 -2
  37. package/src/lib/flower-assets.js +21 -6
  38. package/src/lib/platform-patch-adapters.js +46 -8
  39. package/src/lib/telemetry-context.js +275 -0
  40. package/src/lib/telemetry-files.js +144 -0
  41. package/src/lib/telemetry-operation.js +84 -0
  42. package/src/lib/telemetry-queue.js +310 -0
  43. package/src/lib/telemetry-sender.js +7 -0
  44. package/src/lib/telemetry.js +12 -361
  45. package/src/patches/bundles/flower-platform-integration.json +4 -1
  46. package/src/patches/platforms/claude/session-start-hooks/patch.json +28 -0
  47. package/src/patches/platforms/claude/telemetry-hooks/patch.json +59 -0
  48. package/src/patches/platforms/codex/session-start-hooks/patch.json +3 -2
  49. package/src/patches/platforms/codex/telemetry-hooks/patch.json +59 -0
  50. package/src/plugin/application-service.js +5 -22
  51. package/src/plugin/contracts.js +1 -0
  52. package/src/plugin/lock-reachability.js +37 -0
  53. package/src/plugin/schemas/project-files.js +1 -0
  54. package/src/plugin/sources/gitlab-provider.js +50 -9
@@ -1,8 +1,9 @@
1
- import { FLOWER_UPDATE_HOOK_REL } from "./flower-assets.js";
1
+ import { FLOWER_UPDATE_HOOK_REL, FLOWER_SESSION_HOOK_REL, FLOWER_TELEMETRY_HOOK_REL } from "./flower-assets.js";
2
2
 
3
3
  const CODEX_WORKFLOW_HOOK = ".codex/hooks/inject-workflow-state.py";
4
4
  const CODEX_SESSION_START = ".codex/hooks/session-start.py";
5
5
  const CLAUDE_WORKFLOW_HOOK = ".claude/hooks/inject-workflow-state.py";
6
+ const SESSION_PARTS = ["state", "rules", "stages"];
6
7
 
7
8
  function escapeRe(value) {
8
9
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -31,11 +32,20 @@ function resolveCommand(name, config, pythonCommand) {
31
32
  if (name === "codex-session-start") {
32
33
  return codexBase.replace(CODEX_WORKFLOW_HOOK, CODEX_SESSION_START);
33
34
  }
35
+ if (name === "codex-flower-telemetry") {
36
+ return codexBase.replace(CODEX_WORKFLOW_HOOK, `${FLOWER_TELEMETRY_HOOK_REL} --platform codex`);
37
+ }
34
38
  if (name === "codex-flower-update") {
35
39
  return codexBase.replace(CODEX_WORKFLOW_HOOK, FLOWER_UPDATE_HOOK_REL);
36
40
  }
37
41
  const claudeBase = findHookCommand(config, "UserPromptSubmit", CLAUDE_WORKFLOW_HOOK) ||
38
42
  `${pythonCommand} ${CLAUDE_WORKFLOW_HOOK}`;
43
+ if (name === "claude-session-start") {
44
+ return claudeBase.replace(CLAUDE_WORKFLOW_HOOK, ".claude/hooks/session-start.py");
45
+ }
46
+ if (name === "claude-flower-telemetry") {
47
+ return claudeBase.replace(CLAUDE_WORKFLOW_HOOK, `${FLOWER_TELEMETRY_HOOK_REL} --platform claude`);
48
+ }
39
49
  if (name === "claude-flower-update") {
40
50
  return claudeBase.replace(CLAUDE_WORKFLOW_HOOK, FLOWER_UPDATE_HOOK_REL);
41
51
  }
@@ -62,17 +72,28 @@ function applyJsonHookCommand({ value, operation, pythonCommand = "python3" }) {
62
72
  return { error: `JSON Hook ${event} 必须是数组` };
63
73
  }
64
74
  const groups = existing ? structuredClone(existing) : [];
75
+ const retainedLimits = new Map();
65
76
  for (let index = groups.length - 1; index >= 0; index -= 1) {
66
77
  const group = groups[index];
67
78
  if (!isPlainObject(group)) return { error: `JSON Hook ${event}[${index}] 必须是对象` };
68
79
  if (group.hooks === undefined) group.hooks = [];
69
80
  if (!Array.isArray(group.hooks)) return { error: `JSON Hook ${event}[${index}].hooks 必须是数组` };
70
- group.hooks = group.hooks.filter((hook) => !(
71
- hook?.type === "command" &&
72
- typeof hook.command === "string" &&
73
- hook.command.includes(commandNeedle)
74
- ));
75
- if (group.hooks.length === 0 && !group.matcher) groups.splice(index, 1);
81
+ const matched = group.hooks.filter((hook) => hook?.type === "command" &&
82
+ typeof hook.command === "string" && hook.command.includes(commandNeedle));
83
+ for (const hook of matched) {
84
+ const limit = hook.additionalContextLimit;
85
+ if (limit === undefined) continue;
86
+ if (!Number.isSafeInteger(limit) || limit < 0) {
87
+ return { error: "已有 additionalContextLimit 必须是非负整数" };
88
+ }
89
+ const part = hook.command.match(/(?:^|\s)--part\s+(state|rules|stages)(?=\s|$)/)?.[1] || "legacy";
90
+ if (retainedLimits.has(part) && retainedLimits.get(part) !== limit) {
91
+ return { error: `同一 SessionStart 分段 ${part} 存在冲突的 additionalContextLimit` };
92
+ }
93
+ retainedLimits.set(part, limit);
94
+ }
95
+ group.hooks = group.hooks.filter((hook) => !matched.includes(hook));
96
+ if (group.hooks.length === 0 && (matched.length > 0 || !group.matcher)) groups.splice(index, 1);
76
97
  }
77
98
 
78
99
  if (operation.operation !== "remove") {
@@ -97,7 +118,24 @@ function applyJsonHookCommand({ value, operation, pythonCommand = "python3" }) {
97
118
  else groups.push(group);
98
119
  }
99
120
  if (!Array.isArray(group.hooks)) return { error: "目标 matcher hooks 必须是数组" };
100
- group.hooks.push({ type: "command", command, timeout: content.timeout });
121
+ const parts = content.sessionParts;
122
+ if (parts !== undefined && (
123
+ event !== "SessionStart" ||
124
+ !["codex-session-start", "claude-session-start"].includes(content.commandResolver) ||
125
+ JSON.stringify(parts) !== JSON.stringify(SESSION_PARTS)
126
+ )) {
127
+ return { error: "sessionParts 只允许 Codex / Claude SessionStart 的 state、rules、stages" };
128
+ }
129
+ for (const part of parts || ["legacy"]) {
130
+ // 保留原生路径参数,供现有 bootstrap 检测和下一次迁移识别同一组 handler。
131
+ const nextCommand = parts
132
+ ? command.replace(commandNeedle, `${FLOWER_SESSION_HOOK_REL} --hook ${commandNeedle} --part ${part}`)
133
+ : command;
134
+ const handler = { type: "command", command: nextCommand, timeout: content.timeout };
135
+ const limit = retainedLimits.get(part) ?? retainedLimits.get("legacy");
136
+ if (limit !== undefined) handler.additionalContextLimit = limit;
137
+ group.hooks.push(handler);
138
+ }
101
139
  }
102
140
  config.hooks[event] = groups;
103
141
  return { value: JSON.stringify(config, null, 2) + "\n", source: "structured" };
@@ -0,0 +1,275 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { clearTelemetryQueue, readTelemetryJson, writeTelemetryJson, withTelemetryLock } from "./telemetry-files.js";
5
+ import { flowerConfigDirectory } from "../plugin/sources/user-source-store.js";
6
+ import { ProjectStore } from "../plugin/state/project-store.js";
7
+ import { SKILL_GARDEN_PLUGIN_ID } from "../builtin-plugins/skill-garden/provider.js";
8
+ import { readGitDeveloper } from "./developer.js";
9
+ import { readManifest } from "./manifest.js";
10
+ import { flowerVersion, trellisVersion } from "./versions.js";
11
+
12
+ /** Flower 遥测接收地址。主机名与 `/api/flower-trellis` 路由前缀都由服务端契约固定,缺一不可。 */
13
+ export const FLOWER_TELEMETRY_ENDPOINT = "https://ai-api.hub.flower-cli.com/api/flower-trellis/telemetry";
14
+
15
+ const TELEMETRY_SCHEMA_VERSION = 1;
16
+ const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
17
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
+ const EVENTS = new Set(["version_check", "init_completed", "update_completed"]);
19
+
20
+ /**
21
+ * 规范化可上报的开发者名称。
22
+ *
23
+ * @param {unknown} value 原始名称
24
+ * @returns {string|null} 有效名称,无法使用时返回 null
25
+ */
26
+ function normalizeDeveloperName(value) {
27
+ if (typeof value !== "string") return null;
28
+ const name = value.trim();
29
+ return name && name.length <= 100 ? name : null;
30
+ }
31
+
32
+ /**
33
+ * 返回 Flower 用户级遥测状态文件路径。
34
+ *
35
+ * @param {NodeJS.ProcessEnv} [env] 环境变量
36
+ * @returns {string} 状态文件绝对路径
37
+ */
38
+ export function telemetryStatePath(env = process.env) {
39
+ return path.join(flowerConfigDirectory(env), "telemetry.json");
40
+ }
41
+
42
+ /**
43
+ * 校验并规范化遥测状态。
44
+ *
45
+ * @param {unknown} value 原始状态
46
+ * @returns {object} 规范化状态
47
+ */
48
+ function normalizeTelemetryState(value) {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
50
+ throw new TypeError("遥测状态必须是对象");
51
+ }
52
+ if (value.schemaVersion !== TELEMETRY_SCHEMA_VERSION) {
53
+ throw new TypeError("遥测状态 schemaVersion 无效");
54
+ }
55
+ if (!UUID_PATTERN.test(String(value.deviceId || ""))) {
56
+ throw new TypeError("遥测设备 ID 无效");
57
+ }
58
+ if (typeof value.enabled !== "boolean") {
59
+ throw new TypeError("遥测 enabled 无效");
60
+ }
61
+ const developerName = normalizeDeveloperName(value.developerName);
62
+ if (value.developerName !== undefined && value.developerName !== null && !developerName) {
63
+ throw new TypeError("遥测 developerName 无效");
64
+ }
65
+ for (const field of ["lastAttemptAt", "lastSuccessAt"]) {
66
+ if (value[field] !== null && (
67
+ typeof value[field] !== "string" || Number.isNaN(Date.parse(value[field]))
68
+ )) {
69
+ throw new TypeError(`遥测 ${field} 无效`);
70
+ }
71
+ }
72
+ return {
73
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
74
+ deviceId: value.deviceId,
75
+ developerName,
76
+ enabled: value.enabled,
77
+ lastAttemptAt: value.lastAttemptAt,
78
+ lastSuccessAt: value.lastSuccessAt,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * 读取用户级遥测状态,并区分缺失与损坏。
84
+ *
85
+ * @param {{env?:NodeJS.ProcessEnv}} [options] 读取选项
86
+ * @returns {{status:"missing"|"valid"|"corrupt",state:object|null,path:string,error?:Error}} 状态读取结果
87
+ */
88
+ export function readTelemetryState(options = {}) {
89
+ const filePath = telemetryStatePath(options.env);
90
+ try {
91
+ const value = readTelemetryJson(filePath);
92
+ if (value === null && !fs.existsSync(filePath)) return { status: "missing", state: null, path: filePath };
93
+ const state = normalizeTelemetryState(value);
94
+ return { status: "valid", state, path: filePath };
95
+ } catch (error) {
96
+ if (error?.code === "ENOENT") return { status: "missing", state: null, path: filePath };
97
+ return { status: "corrupt", state: null, path: filePath, error };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 原子写入用户级遥测状态。
103
+ *
104
+ * @param {object} state 已规范化状态
105
+ * @param {{env?:NodeJS.ProcessEnv,randomBytes?:(size:number)=>Buffer}} [options] 写入选项
106
+ * @returns {object} 写入后的状态
107
+ */
108
+ function writeTelemetryState(state, options = {}) {
109
+ const normalized = normalizeTelemetryState(state);
110
+ writeTelemetryJson(telemetryStatePath(options.env), normalized);
111
+ return normalized;
112
+ }
113
+
114
+ /**
115
+ * 创建新的默认遥测状态。
116
+ *
117
+ * @param {{randomUUID?:()=>string}} [options] 随机 ID 注入
118
+ * @returns {object} 默认启用的遥测状态
119
+ */
120
+ function createTelemetryState(options = {}) {
121
+ const randomUUID = options.randomUUID || crypto.randomUUID;
122
+ return {
123
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
124
+ deviceId: randomUUID(),
125
+ developerName: null,
126
+ enabled: true,
127
+ lastAttemptAt: null,
128
+ lastSuccessAt: null,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * 显式启用或停用用户级遥测。
134
+ *
135
+ * 损坏状态只允许通过显式设置修复,普通后台上报不会覆盖现场证据。
136
+ *
137
+ * @param {boolean} enabled 是否启用
138
+ * @param {{env?:NodeJS.ProcessEnv,randomUUID?:()=>string,randomBytes?:(size:number)=>Buffer}} [options] 状态选项
139
+ * @returns {object} 写入后的遥测状态
140
+ */
141
+ export function setTelemetryEnabled(enabled, options = {}) {
142
+ return withTelemetryLock(() => {
143
+ const result = readTelemetryState(options);
144
+ const state = result.status === "valid" ? result.state : createTelemetryState(options);
145
+ const updated = writeTelemetryState({ ...state, enabled }, options);
146
+ if (!enabled || result.status === "corrupt") clearTelemetryQueue(options.env);
147
+ return updated;
148
+ }, options);
149
+ }
150
+
151
+ /**
152
+ * 读取项目 `.trellis/.developer` 中由 Trellis 明确记录的开发者名称。
153
+ *
154
+ * @param {string} target 项目根目录
155
+ * @returns {string|null} 开发者名称
156
+ */
157
+ function readProjectDeveloper(target) {
158
+ try {
159
+ const content = fs.readFileSync(path.join(target, ".trellis", ".developer"), "utf8");
160
+ const line = content.split(/\r?\n/).find((entry) => entry.startsWith("name="));
161
+ return normalizeDeveloperName(line?.slice("name=".length));
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 按项目自报、Git 配置、用户级缓存的顺序解析开发者名称。
169
+ *
170
+ * @param {string} target 项目根目录
171
+ * @param {{developerName?:string|null,env?:NodeJS.ProcessEnv}} [options] 回退选项
172
+ * @returns {string|null} 开发者名称
173
+ */
174
+ export function resolveDeveloperName(target, options = {}) {
175
+ return readProjectDeveloper(target) ||
176
+ normalizeDeveloperName(readGitDeveloper(target, { env: options.env, timeoutMs: 200 })) ||
177
+ normalizeDeveloperName(options.developerName);
178
+ }
179
+
180
+ /**
181
+ * 读取项目当前 Flower 与 Trellis 版本,不采集任何项目路径或仓库信息。
182
+ *
183
+ * @param {string} target 项目根目录
184
+ * @returns {{flower:string|null,trellis:string|null}} 项目版本
185
+ */
186
+ export function readProjectVersions(target) {
187
+ let projectFlower = null;
188
+ let projectTrellis = null;
189
+ try {
190
+ const lock = new ProjectStore(target).readLock();
191
+ const skillGarden = lock?.plugins.find(({ id }) => id === SKILL_GARDEN_PLUGIN_ID);
192
+ projectFlower = skillGarden?.version || readManifest(target)?.flowerVersion || null;
193
+ } catch {
194
+ projectFlower = readManifest(target)?.flowerVersion || null;
195
+ }
196
+ try {
197
+ projectTrellis = fs.readFileSync(path.join(target, ".trellis", ".version"), "utf8").trim();
198
+ } catch {
199
+ projectTrellis = null;
200
+ }
201
+ return {
202
+ flower: VERSION_PATTERN.test(String(projectFlower || "")) ? projectFlower : null,
203
+ trellis: VERSION_PATTERN.test(String(projectTrellis || "")) ? projectTrellis : null,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * 构造严格白名单的匿名遥测载荷。
209
+ *
210
+ * @param {string} target 项目根目录
211
+ * @param {string} event 遥测事件
212
+ * @param {{deviceId:string,developerName?:string|null,env?:NodeJS.ProcessEnv,now?:Date}} options 设备、身份与时间参数
213
+ * @returns {object|null} 可上报载荷;全部身份来源缺失时返回 null
214
+ */
215
+ export function buildTelemetryPayload(target, event, options) {
216
+ if (!EVENTS.has(event)) throw new TypeError(`未知遥测事件:${event}`);
217
+ if (!UUID_PATTERN.test(String(options?.deviceId || ""))) {
218
+ throw new TypeError("遥测设备 ID 无效");
219
+ }
220
+ const now = options.now instanceof Date ? options.now : new Date();
221
+ const resolvedTarget = path.resolve(target);
222
+ const developerName = resolveDeveloperName(resolvedTarget, options);
223
+ // 三种可信身份来源都缺失时无法诚实构造开发者名称,保留静默降级作为最后边界。
224
+ if (!developerName) return null;
225
+ const project = readProjectVersions(resolvedTarget);
226
+ const bundledTrellis = trellisVersion();
227
+ return {
228
+ schema_version: TELEMETRY_SCHEMA_VERSION,
229
+ device_id: options.deviceId,
230
+ event,
231
+ flower_version: flowerVersion(),
232
+ bundled_trellis_version: VERSION_PATTERN.test(bundledTrellis) ? bundledTrellis : null,
233
+ project_flower_version: project.flower,
234
+ project_trellis_version: project.trellis,
235
+ developer_name: developerName,
236
+ platform: process.platform,
237
+ arch: process.arch,
238
+ client_time: now.toISOString(),
239
+ };
240
+ }
241
+
242
+ /**
243
+ * 判断常规遥测是否仍处于上报间隔内。
244
+ *
245
+ * @param {object} state 遥测状态
246
+ * @param {Date} now 当前时间
247
+ * @param {number} intervalHours 上报间隔小时数
248
+ * @returns {boolean} 是否应跳过
249
+ */
250
+ export function isWithinInterval(state, now, intervalHours) {
251
+ if (!state.lastAttemptAt) return false;
252
+ const attemptedAt = new Date(state.lastAttemptAt).getTime();
253
+ if (!Number.isFinite(attemptedAt)) return false;
254
+ return now.getTime() - attemptedAt < intervalHours * 60 * 60 * 1000;
255
+ }
256
+
257
+ /** 锁内重读唯一身份和开关,调用方仅合并自己拥有的字段。
258
+ * @param {Function} callback 同步回调 (state, save)
259
+ * @param {object} options 环境、创建选项
260
+ * @returns {unknown} 回调结果或静默状态
261
+ */
262
+ export function withTelemetryState(callback, options = {}) {
263
+ const env = options.env || process.env;
264
+ if (env.FLOWER_NO_TELEMETRY) return { status: "disabled_by_env" };
265
+ return withTelemetryLock(() => {
266
+ const result = readTelemetryState({ env });
267
+ if (result.status === "corrupt") return { status: "corrupt_state" };
268
+ if (result.status === "missing" && !options.create) return { status: "missing" };
269
+ let state = result.state || createTelemetryState(options);
270
+ return callback(state, (patch = {}) => {
271
+ state = writeTelemetryState({ ...state, ...patch }, { ...options, env });
272
+ return state;
273
+ });
274
+ }, { ...options, env });
275
+ }
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { flowerConfigDirectory } from "../plugin/sources/user-source-store.js";
5
+
6
+ /** 检查目录链,拒绝经由软链接访问遥测状态。
7
+ * @param {string} directory 目录
8
+ * @param {boolean} create 是否创建
9
+ * @returns {void}
10
+ */
11
+ export function telemetryDirectory(directory, create = true) {
12
+ const parent = path.dirname(directory);
13
+ if (parent !== directory) telemetryDirectory(parent, false);
14
+ try {
15
+ const stat = fs.lstatSync(directory);
16
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("遥测目录必须是普通目录");
17
+ } catch (error) {
18
+ if (error.code !== "ENOENT") throw error;
19
+ if (!create) return;
20
+ telemetryDirectory(parent);
21
+ try { fs.mkdirSync(directory, { mode: 0o700 }); }
22
+ catch (failure) { if (failure.code !== "EEXIST") throw failure; telemetryDirectory(directory, false); }
23
+ }
24
+ }
25
+
26
+ /** 读取有界普通 JSON 文件;损坏文件保留原状。
27
+ * @param {string} file 文件
28
+ * @param {unknown} fallback 缺失值
29
+ * @param {number} limit 最大字节数
30
+ * @returns {unknown} JSON
31
+ */
32
+ export function readTelemetryJson(file, fallback = null, limit = 16384) {
33
+ telemetryDirectory(path.dirname(file), false);
34
+ try {
35
+ const stat = fs.lstatSync(file);
36
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit) throw new Error("遥测状态必须是有界普通文件");
37
+ return JSON.parse(fs.readFileSync(file, "utf8"));
38
+ } catch (error) { if (error.code === "ENOENT") return fallback; throw error; }
39
+ }
40
+
41
+ /** 同目录原子替换,避免读到半份状态。
42
+ * @param {string} file 文件
43
+ * @param {unknown} value JSON
44
+ * @returns {void}
45
+ */
46
+ export function writeTelemetryJson(file, value) {
47
+ telemetryDirectory(path.dirname(file));
48
+ try {
49
+ const stat = fs.lstatSync(file);
50
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("遥测状态必须是普通文件");
51
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
52
+ const temporary = `${file}.${crypto.randomUUID()}.tmp`;
53
+ let descriptor;
54
+ try {
55
+ descriptor = fs.openSync(temporary, "wx", 0o600);
56
+ fs.writeFileSync(descriptor, JSON.stringify(value));
57
+ fs.fsyncSync(descriptor);
58
+ fs.closeSync(descriptor);
59
+ descriptor = undefined;
60
+ fs.renameSync(temporary, file);
61
+ } finally {
62
+ if (descriptor !== undefined) fs.closeSync(descriptor);
63
+ fs.rmSync(temporary, { force: true });
64
+ }
65
+ }
66
+
67
+ /** 获取新旧遥测共用的进程锁;锁内回调必须同步且不联网。
68
+ * @param {Function} callback 同步临界区
69
+ * @param {object} options 环境
70
+ * @returns {unknown} 回调结果
71
+ */
72
+ export function withTelemetryLock(callback, options = {}) {
73
+ const directory = flowerConfigDirectory(options.env || process.env);
74
+ telemetryDirectory(directory);
75
+ try { fs.chmodSync(directory, 0o700); } catch { /* Windows 由用户目录权限保护。 */ }
76
+ const lock = path.join(directory, "telemetry.lock");
77
+ const token = crypto.randomUUID();
78
+ const deadline = Date.now() + 500;
79
+ for (;;) {
80
+ if (Date.now() >= deadline) throw new Error("遥测锁繁忙");
81
+ try {
82
+ fs.mkdirSync(lock, { mode: 0o700 });
83
+ writeTelemetryJson(path.join(lock, "owner.json"), { pid: process.pid, token });
84
+ break;
85
+ } catch (error) {
86
+ if (error.code !== "EEXIST") throw error;
87
+ let stat;
88
+ try { stat = fs.lstatSync(lock); }
89
+ catch (failure) { if (failure.code === "ENOENT" && Date.now() < deadline) continue; throw failure; }
90
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("遥测锁无效");
91
+ const owner = readTelemetryJson(path.join(lock, "owner.json"));
92
+ let dead = false;
93
+ if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
94
+ try { process.kill(owner.pid, 0); } catch (failure) { dead = failure.code === "ESRCH"; }
95
+ }
96
+ if (dead || (!owner && Date.now() - stat.mtimeMs > 30000)) {
97
+ // 多个竞争者不能同时清理旧锁,否则第二位可能移走下一位刚创建的新锁。
98
+ const reaper = `${lock}.reap`;
99
+ let claimed = false;
100
+ try {
101
+ fs.mkdirSync(reaper, { mode: 0o700 });
102
+ claimed = true;
103
+ const current = readTelemetryJson(path.join(lock, "owner.json"));
104
+ if (current?.token === owner?.token) {
105
+ const stale = `${lock}.${token}.stale`;
106
+ fs.renameSync(lock, stale);
107
+ fs.rmSync(stale, { recursive: true, force: true });
108
+ }
109
+ } catch { /* 下一轮重试,保留非普通或损坏状态。 */ }
110
+ finally { if (claimed) fs.rmdirSync(reaper); }
111
+ continue;
112
+ }
113
+ if (Date.now() >= deadline) throw new Error("遥测锁繁忙");
114
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
115
+ }
116
+ }
117
+ try { return callback(); }
118
+ finally {
119
+ if (readTelemetryJson(path.join(lock, "owner.json"))?.token === token) fs.rmSync(lock, { recursive: true });
120
+ }
121
+ }
122
+
123
+ /** 返回独立的 v2 目录,不创建文件。
124
+ * @param {object} env 环境
125
+ * @returns {string} 路径
126
+ */
127
+ export function telemetryQueueDirectory(env = process.env) {
128
+ return path.join(flowerConfigDirectory(env), "telemetry-v2");
129
+ }
130
+
131
+ /** 停用时只清理已知普通状态文件,拒绝遍历软链接。
132
+ * @param {object} env 环境
133
+ * @returns {void}
134
+ */
135
+ export function clearTelemetryQueue(env = process.env) {
136
+ const directory = telemetryQueueDirectory(env);
137
+ telemetryDirectory(directory, false);
138
+ if (!fs.existsSync(directory)) return;
139
+ for (const name of fs.readdirSync(directory)) {
140
+ const file = path.join(directory, name);
141
+ const stat = fs.lstatSync(file);
142
+ if (stat.isFile() && !stat.isSymbolicLink() && (name.endsWith(".json") || name.endsWith(".tmp"))) fs.unlinkSync(file);
143
+ }
144
+ }
@@ -0,0 +1,84 @@
1
+ import crypto from "node:crypto";
2
+ import { performance } from "node:perf_hooks";
3
+ import { flowerVersion, trellisVersion } from "./versions.js";
4
+ import { queueTelemetryEvent } from "./telemetry-queue.js";
5
+
6
+ const ERROR_GROUPS = {
7
+ precondition: ["PLUGIN_USAGE_ERROR", "PLUGIN_SOURCE_NOT_FOUND", "PLUGIN_SCHEMA_INVALID", "PLUGIN_STATE_CORRUPT", "PLUGIN_PLATFORM_SELECTION_REQUIRED", "PLUGIN_PLATFORM_UNKNOWN", "PLUGIN_SOURCE_CONFIG_INVALID", "PLUGIN_DEPENDENCY_MISSING"],
8
+ network: ["PLUGIN_REMOTE_REQUEST_FAILED", "PLUGIN_REMOTE_RATE_LIMITED", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND"],
9
+ permission: ["PLUGIN_AUTH_REQUIRED", "PLUGIN_AUTH_FAILED", "PLUGIN_AUTH_SCOPE_INVALID", "PLUGIN_UNSAFE_PATH", "PLUGIN_CAPABILITY_APPROVAL_REQUIRED", "EACCES", "EPERM"],
10
+ conflict: ["PLUGIN_CONTENT_CONFLICT", "PLUGIN_TARGET_DRIFT", "PLUGIN_DEPENDENCY_CONFLICT", "PLUGIN_DEPENDENCY_CYCLE", "PLUGIN_EXTERNAL_VERSION_REUSED", "PLUGIN_INTEGRITY_MISMATCH"],
11
+ upstream: ["FLOWER_UPSTREAM_FAILED"],
12
+ io: ["PLUGIN_IO_ERROR", "PLUGIN_TRANSACTION_FAILED", "PLUGIN_TRANSACTION_REPAIR_REQUIRED", "UPDATE_COMPENSATION_INCOMPLETE", "ENOENT", "ENOSPC", "EROFS"],
13
+ };
14
+
15
+ /** 从结构化错误码取得有限分类,不读取 message/path/stack。
16
+ * @param {unknown} error 异常
17
+ * @returns {object} 分类
18
+ */
19
+ export function classifyTelemetryError(error) {
20
+ const category = Object.entries(ERROR_GROUPS).find(([, codes]) => codes.includes(error?.code))?.[0] || "unknown";
21
+ const stage = error?.code === "UPDATE_COMPENSATION_INCOMPLETE" ? "recover"
22
+ : ({ precondition: "prepare", network: "resolve", permission: "authorize", conflict: "apply", upstream: "upstream", io: "apply" })[category] || "unknown";
23
+ return { error_category: category, failure_stage: stage };
24
+ }
25
+
26
+ /** 标记已开始真实执行,帮助/预览/未确认退出不会产生终态。
27
+ * @param {object} ctx 命令上下文
28
+ * @returns {void}
29
+ */
30
+ export function beginTelemetryOperation(ctx) {
31
+ const operation = ctx.telemetryOperation;
32
+ if (operation && !operation.suppressed && !operation.started) { operation.started = performance.now(); }
33
+ }
34
+
35
+ /** 在 Plugin 把异常转换为退出码前保留有限分类。
36
+ * @param {object} ctx 命令上下文
37
+ * @param {unknown} error 错误
38
+ * @returns {void}
39
+ */
40
+ export function noteTelemetryError(ctx, error) {
41
+ if (ctx.telemetryOperation) {
42
+ ctx.telemetryOperation.error = classifyTelemetryError(error);
43
+ ctx.telemetryOperation.cancelled = error?.telemetryCancelled === true || error?.name === "ExitPromptError" || error?.code === "FLOWER_OPERATION_CANCELLED";
44
+ }
45
+ }
46
+
47
+ /** 至多一次记录终态,并在完成菜单前冻结耗时。
48
+ * @param {object} ctx 命令上下文
49
+ * @param {string} name 期望的外部操作
50
+ * @param {string} outcome 终态
51
+ * @returns {void}
52
+ */
53
+ export function completeTelemetryOperation(ctx, name, outcome = "success") {
54
+ const operation = ctx.telemetryOperation;
55
+ if (!operation || operation.name !== name || !operation.started || operation.completed) return;
56
+ operation.completed = true;
57
+ queueTelemetryEvent(operation.target, { event_id: operation.id, event: "operation_completed", operation: name, outcome,
58
+ ...(outcome === "failure" ? operation.error || classifyTelemetryError(null) : { error_category: null, failure_stage: null }),
59
+ duration_ms: Math.max(0, Math.round(performance.now() - operation.started)), duration_kind: "elapsed" }, operation.options);
60
+ }
61
+
62
+ /** 建立外部操作边界;嵌套调用共享上下文,子进程可仅抑制采集。
63
+ * @param {object} ctx 命令上下文
64
+ * @param {string} name 操作
65
+ * @param {Function} execute 实际命令
66
+ * @param {object} options 遥测依赖
67
+ * @returns {Promise<unknown>} 原命令返回值
68
+ */
69
+ export async function observeTelemetryOperation(ctx, name, execute, options = {}) {
70
+ if (ctx.telemetryOperation || (options.env || process.env).FLOWER_TELEMETRY_PARENT_OPERATION) return execute(ctx);
71
+ const operation = { id: crypto.randomUUID(), name, target: ctx.target, started: null, completed: false,
72
+ suppressed: ctx.passthrough?.includes("--dry-run") || ctx.trellisControlMode === "restoring",
73
+ options: { ...options, runtimeVersion: flowerVersion(), bundledVersion: trellisVersion() } };
74
+ const nested = { ...ctx, telemetryOperation: operation };
75
+ try {
76
+ const result = await execute(nested);
77
+ completeTelemetryOperation(nested, name, operation.cancelled ? "cancelled" : typeof result === "number" && result !== 0 ? "failure" : "success");
78
+ return result;
79
+ } catch (error) {
80
+ if (!operation.error || error?.code === "UPDATE_COMPENSATION_INCOMPLETE") noteTelemetryError(nested, error);
81
+ completeTelemetryOperation(nested, name, operation.cancelled ? "cancelled" : "failure");
82
+ throw error;
83
+ }
84
+ }