flower-trellis 0.6.8 → 0.6.9

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 (34) hide show
  1. package/README.md +24 -1
  2. package/enhancements/0.6/.agents/skills/trellis-route/scripts/route_state.py +2 -1
  3. package/enhancements/0.6/.claude/skills/trellis-route/scripts/route_state.py +2 -1
  4. package/enhancements/0.6/overrides/bundles/shared-hook-runtime.json +1 -1
  5. package/enhancements/0.6/overrides/patches/scripts/windows-streams/common-content.py +10 -0
  6. package/enhancements/0.6/overrides/patches/scripts/windows-streams/common-selector.py +14 -0
  7. package/enhancements/0.6/overrides/patches/scripts/windows-streams/hook-content.py +7 -0
  8. package/enhancements/0.6/overrides/patches/scripts/windows-streams/hook-selector.py +6 -0
  9. package/enhancements/0.6/overrides/patches/scripts/windows-streams/patch.json +186 -0
  10. package/enhancements/0.6/overrides/patches/scripts/windows-streams/prefix-content.py +1 -0
  11. package/enhancements/0.6/overrides/patches/scripts/windows-streams/prefix-selector.py +1 -0
  12. package/enhancements/0.6/scripts/auto_loop.py +21 -10
  13. package/enhancements/0.6/scripts/decision_log.py +7 -5
  14. package/enhancements/0.6/scripts/git_evidence.py +2 -1
  15. package/enhancements/0.6/scripts/pre_check_state.py +1 -1
  16. package/enhancements/0.6/scripts/task_intent.py +4 -0
  17. package/enhancements/0.6/scripts/worktree_setup.py +8 -3
  18. package/enhancements/MANIFEST.json +9 -2
  19. package/package.json +4 -4
  20. package/src/assets/flower_session_start.py +40 -15
  21. package/src/assets/flower_telemetry_hook.py +18 -3
  22. package/src/cli.js +7 -1
  23. package/src/commands/self-check.js +3 -2
  24. package/src/commands/self-update.js +10 -6
  25. package/src/commands/update.js +33 -19
  26. package/src/lib/global-trellis-sync.js +60 -8
  27. package/src/lib/operation-timing.js +50 -0
  28. package/src/lib/self-check.js +14 -54
  29. package/src/lib/terminal-state.js +5 -5
  30. package/src/lib/trellis-runner.js +34 -10
  31. package/src/lib/trellis-update-entry.js +10 -0
  32. package/src/lib/trellis-update-fetch.js +18 -0
  33. package/src/lib/update-check-cache.js +47 -0
  34. package/src/lib/update-check.js +125 -56
@@ -8,10 +8,9 @@ import argparse
8
8
  import json
9
9
  import os
10
10
  import re
11
+ import subprocess
11
12
  import sys
12
- from contextlib import redirect_stdout
13
13
  from importlib.util import module_from_spec, spec_from_file_location
14
- from io import StringIO
15
14
  from pathlib import Path
16
15
 
17
16
 
@@ -86,6 +85,40 @@ def _load_hook(root: Path, hook: str):
86
85
  return module
87
86
 
88
87
 
88
+ def _run_native_hook(root: Path, hook: str, hook_input: dict) -> dict | None:
89
+ """在独立解释器中执行原生 hook,保留真实标准流边界。
90
+
91
+ @param root: 当前部署项目根目录。
92
+ @param hook: 已验证的原生平台 hook 相对路径。
93
+ @param hook_input: 宿主传入并已规范化 cwd 的事件 JSON。
94
+ @return: 原生标准 SessionStart 输出;原生 hook 跳过时返回 None。
95
+ """
96
+ environment = os.environ.copy()
97
+ environment["PYTHONIOENCODING"] = "utf-8"
98
+ result = subprocess.run(
99
+ [sys.executable, "-X", "utf8", str(root / hook)],
100
+ cwd=root,
101
+ env=environment,
102
+ input=json.dumps(hook_input, ensure_ascii=False).encode("utf-8"),
103
+ capture_output=True,
104
+ check=False,
105
+ )
106
+ stderr = result.stderr.decode("utf-8", errors="replace")
107
+ if stderr:
108
+ # 原生诊断属于真实 Hook 证据,不能因包装器捕获输出而被吞掉。
109
+ sys.stderr.write(stderr)
110
+ sys.stderr.flush()
111
+ if result.returncode != 0:
112
+ raise ValueError(f"原生 hook 退出码 {result.returncode}")
113
+ stdout = result.stdout.decode("utf-8-sig")
114
+ if not stdout.strip():
115
+ return None
116
+ output = json.loads(stdout)
117
+ if not isinstance(output, dict):
118
+ raise ValueError("原生 hook 输出必须为 JSON 对象")
119
+ return output
120
+
121
+
89
122
  def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | None:
90
123
  """生成指定分段,只有 state 执行原生主入口的副作用。
91
124
 
@@ -97,21 +130,10 @@ def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | No
97
130
  """
98
131
  if hook not in HOOKS or part not in PARTS:
99
132
  raise ValueError("不支持的 SessionStart hook 或分段")
100
- module = _load_hook(root, hook)
101
- if module.should_skip_injection():
102
- return None
103
133
  if part == "state":
104
- output = StringIO()
105
- original_stdin = sys.stdin
106
- try:
107
- sys.stdin = StringIO(json.dumps(hook_input, ensure_ascii=False))
108
- with redirect_stdout(output):
109
- module.main()
110
- finally:
111
- sys.stdin = original_stdin
112
- if not output.getvalue().strip():
134
+ result = _run_native_hook(root, hook, hook_input)
135
+ if result is None:
113
136
  return None
114
- result = json.loads(output.getvalue())
115
137
  context = result["hookSpecificOutput"]["additionalContext"]
116
138
  if len(WORKFLOW_BLOCK.findall(context)) != 1:
117
139
  raise ValueError("原生启动输出必须包含且仅包含一个 trellis-workflow 块")
@@ -133,6 +155,9 @@ def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | No
133
155
  result["systemMessage"] = "\n".join(filter(None, [result.get("systemMessage"), message]))
134
156
  print(message, file=sys.stderr)
135
157
  else:
158
+ module = _load_hook(root, hook)
159
+ if module.should_skip_injection():
160
+ return None
136
161
  builder = module._build_workflow_toc if hook == HOOKS[0] else module._build_workflow_overview
137
162
  context = split_workflow(builder(root / ".trellis/workflow.md"))[part]
138
163
  result = {"hookSpecificOutput": {"hookEventName": "SessionStart"}}
@@ -5,6 +5,7 @@
5
5
  from __future__ import annotations
6
6
 
7
7
  import json
8
+ import ntpath
8
9
  import os
9
10
  import shutil
10
11
  import subprocess
@@ -57,7 +58,7 @@ def _can_skip(platform: str) -> bool:
57
58
  return hint.get("delivered") is True
58
59
  retry = meta.get("nextRetryAt")
59
60
  return bool(retry and datetime.fromisoformat(retry.replace("Z", "+00:00")).timestamp() > time.time())
60
- except (OSError, ValueError, TypeError, AttributeError):
61
+ except (OSError, ValueError, TypeError, AttributeError, RuntimeError):
61
62
  return False
62
63
 
63
64
 
@@ -86,10 +87,24 @@ def main() -> None:
86
87
  command = shutil.which("flower-trellis")
87
88
  if not command:
88
89
  return
90
+ argv = [command, "telemetry", "record-activity", platform, "--target", target]
91
+ run_options = {}
92
+ if os.name == "nt" and os.path.splitext(command)[1].lower() in {".cmd", ".bat"}:
93
+ processor = os.environ.get("COMSPEC")
94
+ if not processor and os.environ.get("SystemRoot"):
95
+ processor = os.path.join(os.environ["SystemRoot"], "System32", "cmd.exe")
96
+ if not processor or not ntpath.isabs(processor):
97
+ return
98
+ # CMD 的环境占位符只展开一轮;引号和关闭延迟展开共同保留特殊路径字符。
99
+ environment = os.environ.copy()
100
+ environment["FLOWER_ACTIVITY_HOOK_CLI"] = command
101
+ environment["FLOWER_ACTIVITY_HOOK_TARGET"] = target
102
+ argv = f'"{processor}" /d /v:off /s /c ""%FLOWER_ACTIVITY_HOOK_CLI%" telemetry record-activity {platform} --target "%FLOWER_ACTIVITY_HOOK_TARGET%""'
103
+ run_options = {"executable": processor, "env": environment}
89
104
  # 只转交固定事件类型、平台和本地定位参数;Node 独占身份创建及队列写入。
90
- subprocess.run([command, "telemetry", "record-activity", platform, "--target", target],
105
+ subprocess.run(argv,
91
106
  cwd=target, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
92
- stderr=subprocess.DEVNULL, timeout=3, check=False)
107
+ stderr=subprocess.DEVNULL, timeout=3, check=False, **run_options)
93
108
  except (OSError, ValueError, TypeError, subprocess.SubprocessError):
94
109
  return
95
110
 
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import { readManifest } from "./lib/manifest.js";
7
7
  import { ProjectStore } from "./plugin/state/project-store.js";
8
8
  import { runTrellis } from "./lib/trellis-runner.js";
9
9
  import { parseCliArgs } from "./lib/cli-args.js";
10
- import { installWindowsTerminalInputRecovery } from "./lib/terminal-state.js";
10
+ import { installWindowsTerminalInputRecovery, scheduleWindowsTerminalExit } from "./lib/terminal-state.js";
11
11
 
12
12
  /**
13
13
  * flower-trellis CLI 主入口。
@@ -204,6 +204,12 @@ async function main() {
204
204
  const code = await runTrellis([cmd, ...ctx.passthrough], ctx.target);
205
205
  process.exit(code);
206
206
  }
207
+ // 非交互完成不会经过退出菜单;必须等编排、计时和遥测收尾后再释放 Windows CLI。
208
+ if (process.platform === "win32" && (cmd === "init" || cmd === "update")) {
209
+ // Windows 管道写入是异步的,显式退出前等两个输出流排空,避免截断完成行和计时。
210
+ await Promise.all([process.stdout, process.stderr].map((stream) => new Promise((resolve) => stream.write("", resolve))));
211
+ scheduleWindowsTerminalExit({ exitCode: process.exitCode ?? 0 });
212
+ }
207
213
  } catch (err) {
208
214
  console.error(`❌ ${err.message}`);
209
215
  if (process.env.DEBUG || process.env.FLOWER_DEBUG) console.error(err.stack);
@@ -1,3 +1,4 @@
1
+ import { timeOperation } from "../lib/operation-timing.js";
1
2
  import { buildSelfCheck } from "../lib/self-check.js";
2
3
  import { reportTelemetry } from "../lib/telemetry.js";
3
4
  import { hasHelpFlag } from "../lib/cli-args.js";
@@ -39,11 +40,11 @@ export async function selfCheck(ctx) {
39
40
  if (ctx.updateCheck === false) {
40
41
  process.env.FLOWER_NO_UPDATE_CHECK = "1";
41
42
  }
42
- const result = await buildSelfCheck(ctx.target, {
43
+ const result = await timeOperation("self-check", "预检", () => buildSelfCheck(ctx.target, {
43
44
  forceRemote,
44
45
  ignorePromptSuppression: manual,
45
46
  recordPrompt: !manual,
46
47
  onRemoteCheck: () => reportTelemetry(ctx.target, "version_check"),
47
- });
48
+ }));
48
49
  console.log(JSON.stringify(result, null, 2));
49
50
  }
@@ -1,3 +1,4 @@
1
+ import { timeOperation } from "../lib/operation-timing.js";
1
2
  import { observeTelemetryOperation, beginTelemetryOperation, completeTelemetryOperation } from "../lib/telemetry-operation.js";
2
3
  import { spawnSync } from "node:child_process";
3
4
  import {
@@ -129,7 +130,8 @@ function printFlowerUpdateResult(fields) {
129
130
  * @returns {Promise<void>}
130
131
  */
131
132
  export async function selfUpdate(ctx) {
132
- return observeTelemetryOperation(ctx, "self_update", executeSelfUpdate);
133
+ if (hasHelpFlag(ctx.passthrough)) return executeSelfUpdate(ctx);
134
+ return timeOperation("self-update", "流程总计(包含子阶段及交互等待)", () => observeTelemetryOperation(ctx, "self_update", executeSelfUpdate));
133
135
  }
134
136
 
135
137
  /** 执行已建立外部操作上下文的命令。
@@ -152,11 +154,11 @@ async function executeSelfUpdate(ctx) {
152
154
  "--no-update-check",
153
155
  ...projectUpdateForwardArgs(forwarded),
154
156
  ];
155
- const check = await buildSelfCheck(ctx.target, {
157
+ const check = await timeOperation("self-update", "预检", () => buildSelfCheck(ctx.target, {
156
158
  writeCache: !dryRun,
157
159
  forceRemote: !projectOnly,
158
160
  ignorePromptSuppression: true,
159
- });
161
+ }));
160
162
  const shouldInstallFlower = !projectOnly && check.status === "update_available" && check.recommendation;
161
163
  const effectiveSafety = check.safety || safetyState(
162
164
  ctx.target,
@@ -207,7 +209,8 @@ async function executeSelfUpdate(ctx) {
207
209
 
208
210
  beginTelemetryOperation(ctx);
209
211
  if (shouldInstallFlower) {
210
- const res = installFlowerVersion(check.recommendation.version, { cwd: ctx.target });
212
+ console.log(" · 正在安装 Flower 新版本(含依赖与全局同步)");
213
+ const res = timeOperation("self-update", "全局安装(含依赖与postinstall)", () => installFlowerVersion(check.recommendation.version, { cwd: ctx.target }));
211
214
  if (res.status !== 0) {
212
215
  const reason = res.error ? res.error.message : `退出码 ${res.status ?? 1}`;
213
216
  throw Object.assign(new Error(`全局 flower-trellis 升级失败(${reason})`), { code: res.status === 130 ? "FLOWER_OPERATION_CANCELLED" : "FLOWER_UPSTREAM_FAILED" });
@@ -216,13 +219,14 @@ async function executeSelfUpdate(ctx) {
216
219
  console.log(" · 跳过全局 flower-trellis 升级");
217
220
  }
218
221
 
219
- runCommand(
222
+ console.log(" · 正在更新目标项目");
223
+ timeOperation("self-update", "项目更新(含子阶段及交互等待)", () => runCommand(
220
224
  "flower-trellis",
221
225
  projectArgs,
222
226
  ctx.target,
223
227
  "目标项目重叠加失败,请手动运行:" + projectUpdateCommand(ctx.target, forwarded),
224
228
  { ...process.env, FLOWER_TELEMETRY_PARENT_OPERATION: ctx.telemetryOperation?.id || "self_update" },
225
- );
229
+ ));
226
230
 
227
231
  completeTelemetryOperation(ctx, "self_update");
228
232
  const dirty = gitDirtySummary(ctx.target);
@@ -1,3 +1,4 @@
1
+ import { beginOperationTiming, timeOperation } from "../lib/operation-timing.js";
1
2
  import { observeTelemetryOperation, beginTelemetryOperation, completeTelemetryOperation } from "../lib/telemetry-operation.js";
2
3
  import { runTrellis, runTrellisPty } from "../lib/trellis-runner.js";
3
4
  import { hasHelpFlag, trellisUpdatePassthroughArgs } from "../lib/cli-args.js";
@@ -172,20 +173,20 @@ export async function replayPlugins(ctx, target, dryRun, compensationSnapshot =
172
173
  }
173
174
 
174
175
  async function previewCrossVersionUpdate(ctx, currentVersion, targetVersion) {
175
- const sandbox = createUpdateSandbox(ctx.target);
176
+ const sandbox = timeOperation("update", "创建沙箱", () => createUpdateSandbox(ctx.target));
176
177
  console.log(
177
178
  `· 跨版本 dry-run:在项目外沙箱预演 Trellis + Plugin (${currentVersion} → ${targetVersion})`,
178
179
  );
179
180
  try {
180
- const code = await runTrellis(
181
+ const code = await timeOperation("update", "沙箱上游更新", () => runTrellis(
181
182
  ["update", ...sandboxTrellisUpdateArgs(ctx.passthrough)],
182
183
  sandbox.root,
183
- { stripBanner: true },
184
- );
184
+ { stripBanner: true, managedUpdate: true },
185
+ ));
185
186
  if (code !== 0) throw new Error(`沙箱 trellis update 失败(退出码 ${code})`);
186
- await replayPlugins(ctx, sandbox.root, true);
187
+ await timeOperation("update", "沙箱插件预演", () => replayPlugins(ctx, sandbox.root, true));
187
188
  } finally {
188
- disposeUpdateSandbox(sandbox);
189
+ timeOperation("update", "清理沙箱", () => disposeUpdateSandbox(sandbox));
189
190
  }
190
191
  }
191
192
 
@@ -240,7 +241,18 @@ function printBackupRetentionResult(result, output = console) {
240
241
  * @returns {Promise<void>} 升级、强化叠加与备份保留处理完成后返回
241
242
  */
242
243
  export async function update(ctx) {
243
- return observeTelemetryOperation(ctx, "update", executeUpdate);
244
+ if (ctx.finishUpdateTiming || hasHelpFlag(ctx.passthrough)) {
245
+ return observeTelemetryOperation(ctx, "update", executeUpdate);
246
+ }
247
+ const finish = beginOperationTiming("update", "总计(包含子阶段)");
248
+ try {
249
+ return await observeTelemetryOperation({ ...ctx, finishUpdateTiming: finish }, "update", executeUpdate);
250
+ } catch (error) {
251
+ finish(false);
252
+ throw error;
253
+ } finally {
254
+ finish();
255
+ }
244
256
  }
245
257
 
246
258
  /** 执行已建立外部操作上下文的命令。
@@ -262,6 +274,7 @@ async function executeUpdate(ctx) {
262
274
  trellisControlExtendSnapshot: extendSnapshot,
263
275
  }));
264
276
  completeTelemetryOperation(ctx, "update");
277
+ ctx.finishUpdateTiming?.();
265
278
  await showCommandCompletion("update", ctx.target, { passthrough: ctx.passthrough, outcome: "success", output: ctx.trellisControlQuiet ? SILENT_OUTPUT : console });
266
279
  return result;
267
280
  }
@@ -292,34 +305,34 @@ async function executeUpdate(ctx) {
292
305
  await checkForUpdate(ctx, "update");
293
306
 
294
307
  output.log("\n同步全局 Trellis:");
295
- syncGlobalTrellis({ logger: output, stdio: quiet ? "ignore" : "inherit" });
308
+ timeOperation("update", "同步全局Trellis", () => syncGlobalTrellis({ logger: output, stdio: quiet ? "ignore" : "inherit" }));
296
309
 
297
310
  try {
298
311
  compensationSnapshot = !dryRun && !ctx.enhanceOnly
299
- ? createUpdateSnapshot(target)
312
+ ? timeOperation("update", "创建恢复快照", () => createUpdateSnapshot(target))
300
313
  : null;
301
314
  if (useUpdateSandbox) {
302
315
  await previewCrossVersionUpdate(ctx, currentTrellisVersion, targetTrellisVersion);
303
316
  updateSucceeded = true;
304
317
  } else if (!ctx.enhanceOnly) {
305
- const code = await runTrellisPty(
318
+ const code = await timeOperation("update", "上游更新(含交互等待)", () => runTrellisPty(
306
319
  ["update", ...trellisUpdatePassthroughArgs(ctx.passthrough)],
307
320
  target,
308
- { stripBanner: true, ...(quiet ? { stdout: SILENT_OUTPUT } : {}) },
309
- );
321
+ { stripBanner: true, managedUpdate: true, ...(quiet ? { stdout: SILENT_OUTPUT } : {}) },
322
+ ));
310
323
  if (code !== 0) {
311
324
  throw Object.assign(new Error(`trellis update 失败(退出码 ${code}),已中止,未重新叠加`), { code: code === 130 ? "FLOWER_OPERATION_CANCELLED" : "FLOWER_UPSTREAM_FAILED" });
312
325
  }
313
- await replayPlugins(ctx, target, dryRun, compensationSnapshot);
326
+ await timeOperation("update", "插件重放", () => replayPlugins(ctx, target, dryRun, compensationSnapshot));
314
327
  updateSucceeded = true;
315
328
  } else {
316
329
  output.log("· --enhance-only:跳过 trellis update,仅重新叠加强化包");
317
- await replayPlugins(ctx, target, dryRun);
330
+ await timeOperation("update", "插件重放", () => replayPlugins(ctx, target, dryRun));
318
331
  updateSucceeded = true;
319
332
  }
320
333
  } catch (error) {
321
334
  if (compensationSnapshot) {
322
- const recovery = restoreUpdateSnapshot(compensationSnapshot);
335
+ const recovery = timeOperation("update", "失败恢复", () => restoreUpdateSnapshot(compensationSnapshot));
323
336
  if (!recovery.ok) {
324
337
  output.error(` ✗ Update 补偿恢复不完整;快照保留:${recovery.manifestPath}`);
325
338
  for (const failure of recovery.failedPaths) {
@@ -336,22 +349,22 @@ async function executeUpdate(ctx) {
336
349
  throw error;
337
350
  } finally {
338
351
  if (updateSucceeded && !dryRun && !ctx.enhanceOnly) {
339
- const restored = restoreConfigPreserveSnapshot(target, configSnapshot);
352
+ const restored = timeOperation("update", "恢复本地配置", () => restoreConfigPreserveSnapshot(target, configSnapshot));
340
353
  if (restored.restored) {
341
354
  output.log(` ✓ config.yaml 已保留本地配置: ${restored.keys.join(", ")}`);
342
355
  }
343
356
  }
344
357
  if (compensationSnapshot && (updateSucceeded || compensationRecovered)) {
345
- disposeUpdateSnapshot(compensationSnapshot);
358
+ timeOperation("update", "清理快照", () => disposeUpdateSnapshot(compensationSnapshot));
346
359
  }
347
360
  }
348
361
 
349
362
  if (shouldManageBackups) {
350
- const backupResult = pruneUpdateBackups(target, {
363
+ const backupResult = timeOperation("update", "清理旧备份", () => pruneUpdateBackups(target, {
351
364
  retention: backupRetention,
352
365
  beforeSnapshot: backupSnapshot,
353
366
  dryRun,
354
- });
367
+ }));
355
368
  printBackupRetentionResult(backupResult, output);
356
369
  } else if (!ctx.enhanceOnly && backupRetention === 0) {
357
370
  output.log(" · --backup-retention 0:保留全部升级备份");
@@ -361,6 +374,7 @@ async function executeUpdate(ctx) {
361
374
  const telemetryPromise = dryRun
362
375
  ? null
363
376
  : reportTelemetry(target, "update_completed", { force: true });
377
+ if (!ctx.telemetryDeferCompletion) ctx.finishUpdateTiming?.();
364
378
  if (!ctx.telemetryDeferCompletion) await showCommandCompletion("update", target, {
365
379
  passthrough: ctx.passthrough,
366
380
  outcome: dryRun ? "preview" : "success",
@@ -1,3 +1,4 @@
1
+ import { timeOperation } from "./operation-timing.js";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
@@ -74,12 +75,13 @@ export function npmGlobalPrefix() {
74
75
  * 计算 npm 全局 prefix 下的 trellis 可执行文件路径。
75
76
  *
76
77
  * @param {string|null} prefix npm 全局 prefix
78
+ * @param {string} [platform] 当前平台
77
79
  * @returns {string|null} 可执行文件路径,不可推导或文件不存在时返回 null
78
80
  */
79
- export function trellisBinInPrefix(prefix) {
81
+ export function trellisBinInPrefix(prefix, platform = process.platform) {
80
82
  if (!prefix) return null;
81
83
  const candidates =
82
- process.platform === "win32"
84
+ platform === "win32"
83
85
  ? ["trellis.cmd", "trellis.ps1", "trellis"].map((name) => path.join(prefix, name))
84
86
  : [path.join(prefix, "bin", "trellis")];
85
87
  return candidates.find((candidate) => fs.existsSync(candidate)) || null;
@@ -103,6 +105,48 @@ export function globalTrellisVersion(cwd = os.tmpdir(), command = "trellis") {
103
105
  return extractLastVersion(`${res.stdout || ""}\n${res.stderr || ""}`);
104
106
  }
105
107
 
108
+ /**
109
+ * 从可验证的 npm 安装及启动器读取全局版本,无法证明归属时返回 null。
110
+ * @param {string|null} prefix npm 全局 prefix
111
+ * @param {string|null} command prefix 内实际启动器
112
+ * @param {string} [platform] 当前平台
113
+ * @returns {string|null} 实际安装版本或兼容探测信号
114
+ */
115
+ export function installedGlobalTrellisVersion(prefix, command, platform = process.platform) {
116
+ if (!prefix || !command) return null;
117
+ try {
118
+ const root = path.join(prefix, ...(platform === "win32" ? [] : ["lib"]), "node_modules", TRELLIS_PACKAGE);
119
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
120
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.trellis;
121
+ if (pkg.name !== TRELLIS_PACKAGE || !VERSION_RE.test(pkg.version) || typeof bin !== "string") return null;
122
+ const target = path.resolve(root, bin);
123
+ const relative = path.relative(root, target);
124
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || !fs.statSync(target).isFile()) return null;
125
+ const realTarget = fs.realpathSync(target);
126
+ const realRelative = path.relative(fs.realpathSync(root), realTarget);
127
+ if (!realRelative || realRelative.startsWith("..") || path.isAbsolute(realRelative)) return null;
128
+ if (platform !== "win32") {
129
+ if (fs.realpathSync(command) !== realTarget) return null;
130
+ } else {
131
+ if (path.resolve(command) !== path.resolve(prefix, "trellis.cmd")) return null;
132
+ // 只接受 npm cmd-shim 的标准 Node 启动器全文;自定义包装可能另行调用其它版本。
133
+ const binRelative = path.relative(prefix, target).split(path.sep).join("\\");
134
+ const expected = [
135
+ "@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start",
136
+ "SETLOCAL", "CALL :find_dp0", "", 'IF EXIST "%dp0%\\node.exe" (',
137
+ ' SET "_prog=%dp0%\\node.exe"', ') ELSE (', ' SET "_prog=node"',
138
+ ' SET PATHEXT=%PATHEXT:;.JS;=;%', ')', '',
139
+ `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${binRelative}" %*`,
140
+ "",
141
+ ].join("\n");
142
+ if (fs.readFileSync(command, "utf8").replaceAll("\r\n", "\n") !== expected) return null;
143
+ }
144
+ return pkg.version;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
106
150
  /**
107
151
  * 同步本机全局 Trellis 到 flower-trellis 捆绑版本。
108
152
  *
@@ -110,6 +154,10 @@ export function globalTrellisVersion(cwd = os.tmpdir(), command = "trellis") {
110
154
  * @param {{log:(message:string)=>void}} [opts.logger] 输出对象,默认使用 console
111
155
  * @param {string} [opts.cwd] 执行目录,默认当前目录
112
156
  * @param {"inherit"|"ignore"} [opts.stdio] npm 安装子进程输出模式
157
+ * @param {string} [opts.prefix] 指定 npm prefix,用于隔离测试
158
+ * @param {string} [opts.platform] 平台测试替身
159
+ * @param {Function} [opts.spawn] npm 安装测试替身
160
+ * @param {Function} [opts.probeVersion] 兼容版本探测测试替身
113
161
  * @returns {{targetVersion:string,currentVersion:string|null,installed:boolean,skipped:boolean,command:string}} 同步结果
114
162
  */
115
163
  export function syncGlobalTrellis(opts = {}) {
@@ -129,9 +177,13 @@ export function syncGlobalTrellis(opts = {}) {
129
177
  };
130
178
  }
131
179
 
132
- const prefix = npmGlobalPrefix();
133
- const prefixBin = trellisBinInPrefix(prefix);
134
- const currentVersion = prefixBin ? globalTrellisVersion(os.tmpdir(), prefixBin) : null;
180
+ const platform = opts.platform || process.platform;
181
+ const prefix = timeOperation("全局Trellis", "定位安装", () => opts.prefix ?? npmGlobalPrefix());
182
+ const prefixBin = trellisBinInPrefix(prefix, platform);
183
+ const currentVersion = timeOperation("全局Trellis", "读取版本", () => (
184
+ installedGlobalTrellisVersion(prefix, prefixBin, platform) ||
185
+ (prefixBin ? (opts.probeVersion || globalTrellisVersion)(os.tmpdir(), prefixBin) : null)
186
+ ));
135
187
  if (currentVersion === targetVersion) {
136
188
  logger.log(` ✓ 全局 Trellis 已是 ${targetVersion}`);
137
189
  return {
@@ -149,11 +201,11 @@ export function syncGlobalTrellis(opts = {}) {
149
201
  logger.log(` · 安装全局 Trellis:${targetVersion}`);
150
202
  }
151
203
 
152
- const res = spawnSync("npm", ["install", "-g", `${TRELLIS_PACKAGE}@${targetVersion}`], {
204
+ const res = timeOperation("全局Trellis", "安装", () => (opts.spawn || spawnSync)("npm", ["install", "-g", `${TRELLIS_PACKAGE}@${targetVersion}`], {
153
205
  cwd,
154
206
  stdio: opts.stdio || "inherit",
155
- shell: process.platform === "win32",
156
- });
207
+ shell: platform === "win32",
208
+ }));
157
209
 
158
210
  if (res.status !== 0) {
159
211
  const reason = res.error ? res.error.message : `退出码 ${res.status ?? 1}`;
@@ -0,0 +1,50 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ /**
4
+ * 开始一个本地诊断阶段;父阶段包含子阶段,不能相加作为总耗时。
5
+ * @param {string} scope 固定操作名,不得传入用户路径或参数
6
+ * @param {string} stage 固定阶段名
7
+ * @param {{env?:object,output?:{write:Function},now?:Function}} [options] 运行环境与测试替身
8
+ * @returns {(success?:boolean)=>void} 幂等结束函数
9
+ */
10
+ export function beginOperationTiming(scope, stage, options = {}) {
11
+ if ((options.env || process.env).FLOWER_TIMING !== "1") return () => {};
12
+ const output = options.output || process.stderr;
13
+ const now = options.now || (() => performance.now());
14
+ const started = now();
15
+ let finished = false;
16
+ const write = (message) => {
17
+ try { output.write(message); } catch { /* 诊断流失效不能影响升级结果。 */ }
18
+ };
19
+ write(` · [耗时 ${scope}/${stage}] 开始\n`);
20
+ return (success = true) => {
21
+ if (finished) return;
22
+ finished = true;
23
+ write(` · [耗时 ${scope}/${stage}] ${success ? "完成" : "失败"} ${Math.max(0, Math.round(now() - started))} ms\n`);
24
+ };
25
+ }
26
+
27
+ /**
28
+ * 计时同步或异步操作,保留返回值、退出码和原始异常。
29
+ * @template T
30
+ * @param {string} scope 固定操作名
31
+ * @param {string} stage 固定阶段名
32
+ * @param {()=>T} operation 原操作
33
+ * @param {object} [options] 计时依赖
34
+ * @returns {T} 原操作的返回值
35
+ */
36
+ export function timeOperation(scope, stage, operation, options = {}) {
37
+ const finish = beginOperationTiming(scope, stage, options);
38
+ const complete = (value) => {
39
+ const code = typeof value === "number" ? value : value?.status;
40
+ finish(value !== null && value?.ok !== false && !value?.error && !value?.signal && (typeof code !== "number" || code === 0));
41
+ return value;
42
+ };
43
+ const fail = (error) => { finish(false); throw error; };
44
+ try {
45
+ const result = operation();
46
+ return result && typeof result.then === "function" ? result.then(complete, fail) : complete(result);
47
+ } catch (error) {
48
+ return fail(error);
49
+ }
50
+ }
@@ -4,10 +4,11 @@ import path from "node:path";
4
4
  import { readManifest, readUpdateCheck, writeUpdateCheck } from "./manifest.js";
5
5
  import {
6
6
  buildReleaseNotesSummary,
7
- fetchPackageUpdateMetadata,
7
+ createUpdateMetadataReader,
8
8
  getUpdateRecommendation,
9
9
  isPrerelease,
10
10
  } from "./update-check.js";
11
+ import { isRemoteCacheFresh, cachedReleaseNotes } from "./update-check-cache.js";
11
12
  import { isRunningViaNpx } from "./runtime-env.js";
12
13
  import { flowerVersion, trellisVersion } from "./versions.js";
13
14
  import { ProjectStore } from "../plugin/state/project-store.js";
@@ -37,48 +38,6 @@ function readProjectTrellisVersion(target) {
37
38
  }
38
39
  }
39
40
 
40
- /** 判断远程探测缓存是否仍在 interval 内。 */
41
- function isRemoteCacheFresh(updateCheck, now = new Date()) {
42
- if (updateCheck.lastStatus === "offline" || updateCheck.lastErrorCode) return false;
43
- if (!updateCheck.lastRemote) return false;
44
- if (!updateCheck.lastCheckedAt) return false;
45
- const checkedAt = new Date(updateCheck.lastCheckedAt).getTime();
46
- if (!Number.isFinite(checkedAt)) return false;
47
- return now.getTime() - checkedAt < updateCheck.intervalHours * 60 * 60 * 1000;
48
- }
49
-
50
- /**
51
- * 判断缓存的 release notes 是否匹配本次检查范围。
52
- *
53
- * @param {object} updateCheck 归一化 updateCheck 配置
54
- * @param {{from:string|null,to:string|null,channel:string,reason:string}|null} range 本次期望范围
55
- * @returns {object|null} 可复用的缓存摘要
56
- */
57
- function cachedReleaseNotes(updateCheck, range) {
58
- const cached = updateCheck.lastReleaseNotes;
59
- if (!cached || !range || cached.unavailable) return null;
60
- const cachedRange = cached.range || {};
61
- if (
62
- cachedRange.from !== range.from ||
63
- cachedRange.to !== range.to ||
64
- cachedRange.channel !== range.channel
65
- ) {
66
- return null;
67
- }
68
- if (!Array.isArray(cached.versions) || !cached.versions.length) return null;
69
- // 同一版本范围的内容相同;reason 只是触发路径,不能让项目追平场景丢失摘要。
70
- return {
71
- ...cached,
72
- range: {
73
- ...cachedRange,
74
- from: range.from,
75
- to: range.to,
76
- channel: range.channel,
77
- reason: range.reason,
78
- },
79
- };
80
- }
81
-
82
41
  /**
83
42
  * 构造新版升级 release notes 范围。
84
43
  *
@@ -544,7 +503,7 @@ export function safetyState(target, status, command) {
544
503
  * 构建启动自更新检查结果。
545
504
  *
546
505
  * @param {string} target 目标项目根
547
- * @param {{writeCache?: boolean, forceRemote?: boolean, fetchMetadata?: () => Promise<object|null>,onRemoteCheck?:()=>Promise<unknown>,recordPrompt?:boolean,ignorePromptSuppression?:boolean}} options 检查选项
506
+ * @param {{writeCache?:boolean,forceRemote?:boolean,fetchMetadata?:Function,fetchTags?:Function,fetchImpl?:Function,timeoutMs?:number,onRemoteCheck?:()=>Promise<unknown>,recordPrompt?:boolean,ignorePromptSuppression?:boolean}} [options] 检查选项
548
507
  * @returns {Promise<object>} 结构化检查结果
549
508
  */
550
509
  export async function buildSelfCheck(target, options = {}) {
@@ -552,9 +511,8 @@ export async function buildSelfCheck(target, options = {}) {
552
511
  const forceRemote = options.forceRemote === true;
553
512
  const recordPrompt = options.recordPrompt === true;
554
513
  const ignorePromptSuppression = options.ignorePromptSuppression === true;
555
- const fetchMetadata = typeof options.fetchMetadata === "function"
556
- ? options.fetchMetadata
557
- : fetchPackageUpdateMetadata;
514
+ const reader = createUpdateMetadataReader(options);
515
+ const fetchMetadata = reader.readMetadata;
558
516
  const onRemoteCheck = typeof options.onRemoteCheck === "function"
559
517
  ? options.onRemoteCheck
560
518
  : null;
@@ -698,11 +656,11 @@ export async function buildSelfCheck(target, options = {}) {
698
656
  };
699
657
  }
700
658
 
701
- const [metadata] = await Promise.all([
702
- safeFetchPackageUpdateMetadata(fetchMetadata),
659
+ const [remoteTags] = await Promise.all([
660
+ reader.readTags(),
703
661
  onRemoteCheck ? Promise.resolve().then(onRemoteCheck).catch(() => null) : null,
704
662
  ]);
705
- if (!metadata) {
663
+ if (!remoteTags) {
706
664
  const resultBase = persistRemoteCache({
707
665
  lastStatus: "offline",
708
666
  lastErrorCode: "fetch_failed",
@@ -726,17 +684,19 @@ export async function buildSelfCheck(target, options = {}) {
726
684
  };
727
685
  }
728
686
 
729
- tags = metadata.tags;
687
+ tags = remoteTags;
730
688
  const recommendation = getUpdateRecommendation(currentFlower, tags);
731
689
  const remoteStatus = recommendation ? "update_available" : "up_to_date";
732
690
  const releaseNotesRange = recommendation
733
691
  ? updateReleaseNotesRange(currentFlower, recommendation)
734
- : projectReleaseNotesRange(projectFlower, currentFlower);
735
- const releaseNotes = releaseNotesFromMetadata(metadata, releaseNotesRange);
692
+ : projectOutOfSync ? projectReleaseNotesRange(projectFlower, currentFlower) : null;
693
+ const releaseNotes = cachedReleaseNotes(updateCheck, releaseNotesRange) || (releaseNotesRange
694
+ ? releaseNotesFromMetadata(await fetchMetadata(), releaseNotesRange)
695
+ : null);
736
696
  const resultBase = persistRemoteCache({
737
697
  lastCheckedAt: now.toISOString(),
738
698
  lastRemote: tags,
739
- lastReleaseNotes: releaseNotes && !releaseNotes.unavailable ? releaseNotes : null,
699
+ ...(releaseNotes && !releaseNotes.unavailable ? { lastReleaseNotes: releaseNotes } : {}),
740
700
  lastStatus: remoteStatus,
741
701
  lastErrorCode: null,
742
702
  });