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
@@ -41,13 +41,13 @@ export function installWindowsTerminalInputRecovery(options = {}) {
41
41
  }
42
42
 
43
43
  /**
44
- * 在 Windows 交互完成页选择“退出”后恢复终端,并显式结束 CLI 进程。
44
+ * 在 Windows 命令完成或交互完成页选择“退出”后恢复终端,并显式结束 CLI 进程。
45
45
  *
46
46
  * node-pty 的 Windows ConPTY worker 在子进程自然退出后仍可能持有 MessagePort / Socket,
47
- * 因此不能只依赖 Node 事件循环自然清空。这里仅在用户已经选择退出时执行,并把实际退出
48
- * 延后到下一轮事件循环,让 Inquirer 完成当前输出清理。
47
+ * 因此不能只依赖 Node 事件循环自然清空。调用者必须确认顶层命令已结束或用户已经选择退出,
48
+ * 并把实际退出延后到下一轮事件循环,让 Inquirer 完成当前输出清理。
49
49
  *
50
- * @param {{platform?:string,input?:{isTTY?:boolean,isRaw?:boolean,setRawMode?:(value:boolean)=>unknown,pause?:()=>unknown},output?:{isTTY?:boolean,write:(value:string)=>unknown},schedule?:(callback:()=>void)=>unknown,exitProcess?:(code:number)=>unknown}} [options] 终端、调度器与退出函数测试注入
50
+ * @param {{platform?:string,input?:{isTTY?:boolean,isRaw?:boolean,setRawMode?:(value:boolean)=>unknown,pause?:()=>unknown},output?:{isTTY?:boolean,write:(value:string)=>unknown},schedule?:(callback:()=>void)=>unknown,exitProcess?:(code:number)=>unknown,exitCode?:number}} [options] 终端、调度器、退出码与退出函数测试注入
51
51
  * @returns {boolean} 是否安排了 Windows CLI 显式退出
52
52
  */
53
53
  export function scheduleWindowsTerminalExit(options = {}) {
@@ -73,6 +73,6 @@ export function scheduleWindowsTerminalExit(options = {}) {
73
73
 
74
74
  const schedule = options.schedule ?? ((callback) => setTimeout(callback, 0));
75
75
  const exitProcess = options.exitProcess ?? ((code) => process.exit(code));
76
- schedule(() => exitProcess(0));
76
+ schedule(() => exitProcess(options.exitCode ?? 0));
77
77
  return true;
78
78
  }
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { createRequire } from "node:module";
4
5
  import { spawn } from "node:child_process";
5
6
  import { createInterface } from "node:readline";
@@ -72,6 +73,9 @@ const UPSTREAM_UPGRADE_NOTICE = /Your CLI \(([^)]+)\) is behind npm\b/;
72
73
  */
73
74
  const UPSTREAM_UPGRADE_ACTION = /^Run:\s*trellis upgrade$/;
74
75
 
76
+ /** 受管更新由宿主说明版本查询策略,避免依赖上游终端输出格式。 */
77
+ const MANAGED_UPDATE_NOTICE = " · Trellis 版本由 Flower 固定,已跳过独立版本查询";
78
+
75
79
  /**
76
80
  * 正文开始后继续按行过滤的行数上限。
77
81
  *
@@ -89,16 +93,33 @@ const UPSTREAM_NOTICE_LINE_BUDGET = 40;
89
93
  * 让用户仍然知道版本检查发生过、以及为什么不需要照做。
90
94
  *
91
95
  * @param {string} line 未去色的原始输出行
96
+ * @param {{managedUpdate?:boolean}} [options] 是否使用 Flower 管理的更新入口
92
97
  * @returns {string|null|undefined} 替换文案;null 表示丢弃该行;undefined 表示不匹配
93
98
  */
94
- export function rewriteUpstreamUpgradeNotice(line) {
99
+ export function rewriteUpstreamUpgradeNotice(line, options = {}) {
95
100
  const t = stripAnsi(line).trim();
101
+ if (options.managedUpdate && /^Latest on npm:\s+\(unable to fetch\)$/.test(t)) {
102
+ return MANAGED_UPDATE_NOTICE;
103
+ }
96
104
  const notice = UPSTREAM_UPGRADE_NOTICE.exec(t);
97
105
  if (notice) return ` · Trellis 版本由 Flower 固定(${notice[1]}),已忽略上游 npm 升级提示`;
98
106
  if (UPSTREAM_UPGRADE_ACTION.test(t)) return null;
99
107
  return undefined;
100
108
  }
101
109
 
110
+ /**
111
+ * 构造捆绑 Trellis 启动参数,仅显式管理的 update 经过隔离引导。
112
+ * @param {string[]} args 原始上游参数
113
+ * @param {{managedUpdate?:boolean}} [options] 内部启动选项
114
+ * @returns {string[]} 传给 Node 的文件与参数
115
+ */
116
+ export function trellisLaunchArgs(args, options = {}) {
117
+ const bin = resolveTrellisBin();
118
+ return options.managedUpdate && args[0] === "update"
119
+ ? [fileURLToPath(new URL("./trellis-update-entry.js", import.meta.url)), bin, ...args]
120
+ : [bin, ...args];
121
+ }
122
+
102
123
  /**
103
124
  * 用当前 node 执行 trellis bin(普通 spawn),透传子命令与参数。
104
125
  *
@@ -106,14 +127,14 @@ export function rewriteUpstreamUpgradeNotice(line) {
106
127
  *
107
128
  * @param {string[]} args
108
129
  * @param {string} cwd
109
- * @param {object} [opts] { stripBanner }
130
+ * @param {object} [opts] { stripBanner, managedUpdate }
110
131
  * @returns {Promise<number>} 退出码(信号终止返回 128)
111
132
  */
112
133
  export function runTrellis(args, cwd, opts = {}) {
113
- const bin = resolveTrellisBin();
134
+ const launchArgs = trellisLaunchArgs(args, opts);
114
135
  const strip = opts.stripBanner;
115
136
  return new Promise((resolve, reject) => {
116
- const child = spawn(process.execPath, [bin, ...args], {
137
+ const child = spawn(process.execPath, launchArgs, {
117
138
  cwd,
118
139
  stdio: strip ? ["inherit", "pipe", "inherit"] : "inherit",
119
140
  env: strip ? { ...process.env, FORCE_COLOR: "1" } : process.env,
@@ -122,7 +143,7 @@ export function runTrellis(args, cwd, opts = {}) {
122
143
  const banner = trellisBannerLines();
123
144
  const rl = createInterface({ input: child.stdout });
124
145
  rl.on("line", (line) => {
125
- const notice = rewriteUpstreamUpgradeNotice(line);
146
+ const notice = rewriteUpstreamUpgradeNotice(line, { managedUpdate: opts.managedUpdate && args[0] === "update" });
126
147
  if (notice !== undefined) {
127
148
  if (notice !== null) process.stdout.write(notice + "\n");
128
149
  return;
@@ -156,11 +177,12 @@ export function runTrellis(args, cwd, opts = {}) {
156
177
  *
157
178
  * @param {string[]} args
158
179
  * @param {string} cwd
159
- * @param {object} [opts] { stripBanner, ptySpawn, stdin, stdout, platform }
180
+ * @param {object} [opts] { stripBanner, managedUpdate, ptySpawn, stdin, stdout, platform }
160
181
  * @returns {Promise<number>} 退出码(信号终止返回 128)
161
182
  */
162
183
  export function runTrellisPty(args, cwd, opts = {}) {
163
- const bin = resolveTrellisBin();
184
+ const launchArgs = trellisLaunchArgs(args, opts);
185
+ const managedUpdate = opts.managedUpdate && args[0] === "update";
164
186
  const stdin = opts.stdin || process.stdin;
165
187
  const stdout = opts.stdout || process.stdout;
166
188
  const platform = opts.platform ?? process.platform;
@@ -168,7 +190,7 @@ export function runTrellisPty(args, cwd, opts = {}) {
168
190
  return new Promise((resolve, reject) => {
169
191
  let child;
170
192
  try {
171
- child = ptySpawn(process.execPath, [bin, ...args], {
193
+ child = ptySpawn(process.execPath, launchArgs, {
172
194
  name: "xterm-256color",
173
195
  cols: stdout.columns || 80,
174
196
  rows: stdout.rows || 30,
@@ -181,6 +203,8 @@ export function runTrellisPty(args, cwd, opts = {}) {
181
203
  return;
182
204
  }
183
205
 
206
+ // ConPTY 可能从首帧就进入原样透传;在转发前说明策略,不改写交互控制序列。
207
+ if (managedUpdate) stdout.write(MANAGED_UPDATE_NOTICE + "\r\n");
184
208
  const bannerSet = trellisBannerLines();
185
209
  let phase = opts.stripBanner ? "header" : "raw";
186
210
  let noticeBudget = UPSTREAM_NOTICE_LINE_BUDGET;
@@ -211,9 +235,9 @@ export function runTrellisPty(args, cwd, opts = {}) {
211
235
  stdout.write(line + "\r\n");
212
236
  continue;
213
237
  }
214
- const notice = rewriteUpstreamUpgradeNotice(line);
238
+ const notice = rewriteUpstreamUpgradeNotice(line, { managedUpdate });
215
239
  if (notice !== undefined) {
216
- if (notice !== null) stdout.write(notice + "\r\n");
240
+ if (notice !== null && !(managedUpdate && notice === MANAGED_UPDATE_NOTICE)) stdout.write(notice + "\r\n");
217
241
  continue;
218
242
  }
219
243
  if (phase === "header") {
@@ -0,0 +1,10 @@
1
+ /** Flower 管理的 update 子进程入口;只影响本进程,不改安装目录或全局环境。 */
2
+ import { pathToFileURL } from "node:url";
3
+ import { withoutTrellisUpdateNotice } from "./trellis-update-fetch.js";
4
+
5
+ const [bin, ...args] = process.argv.slice(2);
6
+ if (!bin || args[0] !== "update") throw new Error("Flower 上游更新入口只接受 update");
7
+ // Commander 需要原始 CLI argv,不能使用 eval 入口或让额外的引导文件成为子命令。
8
+ process.argv = [process.execPath, bin, ...args];
9
+ globalThis.fetch = withoutTrellisUpdateNotice(globalThis.fetch);
10
+ await import(pathToFileURL(bin).href);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 为 Flower 管理的更新隔离上游独立版本提示,保留所有实际资源请求。
3
+ *
4
+ * 上游 0.6.14 的 getLatestNpmVersion 只把该响应用于提示,且已有失败降级。
5
+ * 返回失败而非虚构版本,避免让远程事实与捆绑版本混淆。
6
+ * @param {Function} originalFetch 原 fetch
7
+ * @returns {Function} 只跳过精确 GET 提示请求的 fetch
8
+ */
9
+ export function withoutTrellisUpdateNotice(originalFetch) {
10
+ return function fetchWithoutNotice(input, options) {
11
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input?.url;
12
+ const method = String(options?.method || input?.method || "GET").toUpperCase();
13
+ if (method === "GET" && url === "https://registry.npmjs.org/@mindfoldhq/trellis/latest") {
14
+ return Promise.reject(new Error("Trellis 版本由 Flower 管理,跳过独立版本查询"));
15
+ }
16
+ return originalFetch(input, options);
17
+ };
18
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 判断远程探测缓存是否仍在配置的 interval 内。
3
+ * @param {object} updateCheck 归一化缓存
4
+ * @param {Date} [now] 当前时间
5
+ * @returns {boolean} 是否可复用远端版本证据
6
+ */
7
+ export function isRemoteCacheFresh(updateCheck, now = new Date()) {
8
+ if (updateCheck.lastStatus === "offline" || updateCheck.lastErrorCode) return false;
9
+ if (!updateCheck.lastRemote) return false;
10
+ if (!updateCheck.lastCheckedAt) return false;
11
+ const checkedAt = new Date(updateCheck.lastCheckedAt).getTime();
12
+ if (!Number.isFinite(checkedAt)) return false;
13
+ return now.getTime() - checkedAt < updateCheck.intervalHours * 60 * 60 * 1000;
14
+ }
15
+
16
+ /**
17
+ * 判断缓存的 release notes 是否匹配本次检查范围。
18
+ *
19
+ * @param {object} updateCheck 归一化 updateCheck 配置
20
+ * @param {{from:string|null,to:string|null,channel:string,reason:string}|null} range 本次期望范围
21
+ * @returns {object|null} 可复用的缓存摘要
22
+ */
23
+ export function cachedReleaseNotes(updateCheck, range) {
24
+ const cached = updateCheck.lastReleaseNotes;
25
+ if (!cached || !range || cached.unavailable) return null;
26
+ const cachedRange = cached.range || {};
27
+ if (
28
+ cachedRange.from !== range.from ||
29
+ cachedRange.to !== range.to ||
30
+ cachedRange.channel !== range.channel
31
+ ) {
32
+ return null;
33
+ }
34
+ if (!Array.isArray(cached.versions) || !cached.versions.length) return null;
35
+ // 同一版本范围的内容相同;reason 只是触发路径,不能让项目追平场景丢失摘要。
36
+ return {
37
+ ...cached,
38
+ range: {
39
+ ...cachedRange,
40
+ from: range.from,
41
+ to: range.to,
42
+ channel: range.channel,
43
+ reason: range.reason,
44
+ },
45
+ };
46
+ }
47
+
@@ -1,3 +1,6 @@
1
+ import { beginOperationTiming, timeOperation } from "./operation-timing.js";
2
+ import { performance } from "node:perf_hooks";
3
+ import { isRemoteCacheFresh, cachedReleaseNotes } from "./update-check-cache.js";
1
4
  import { spawnSync } from "node:child_process";
2
5
  import { confirm } from "@inquirer/prompts";
3
6
  import chalk from "chalk";
@@ -84,52 +87,92 @@ function parseReleaseNotesByVersion(json) {
84
87
  }
85
88
 
86
89
  /**
87
- * 取 npm 上 flower-trellis 的更新 metadata。
88
- *
89
- * 一次请求 registry 根文档,同时解析 dist-tags 与各版本的 flowerReleaseNotes。任何失败
90
- * (离线/超时/非 200/解析异常)一律返回 null,调用方继续主流程。
91
- *
92
- * @returns {Promise<{tags:{latest:string|null,beta:string|null},releaseNotesByVersion:Record<string,{version:string,body:string,truncated:boolean,source:string}>}|null>} 更新 metadata
90
+ * 在同一截止时间内读取 registry JSON,预算覆盖响应头及完整响应体。
91
+ * @param {string} endpoint registry 相对路径
92
+ * @param {{deadline?:number,timeoutMs?:number,fetchImpl?:Function,abortController?:AbortController}} [options] 请求预算与测试替身
93
+ * @returns {Promise<object|null>} JSON 或降级空值
93
94
  */
94
- export async function fetchPackageUpdateMetadata() {
95
- const ac = new AbortController();
96
- const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
95
+ async function fetchRegistryJson(endpoint, options = {}) {
96
+ const remaining = (options.deadline ?? performance.now() + (options.timeoutMs ?? TIMEOUT_MS)) - performance.now();
97
+ const ac = options.abortController ?? new AbortController();
98
+ if (remaining <= 0 || ac.signal.aborted) return null;
99
+ // 共享取消状态持久记录超时,避免计时器与单调时钟边界不同步时再次发请求。
100
+ const timer = setTimeout(() => ac.abort(), Math.ceil(remaining));
97
101
  try {
98
- const res = await fetch(`${REGISTRY}/${PKG}`, {
102
+ const res = await (options.fetchImpl || fetch)(`${REGISTRY}/${endpoint}`, {
99
103
  signal: ac.signal,
100
104
  headers: { Accept: "application/json" },
101
105
  });
102
- if (!res.ok) return null; // 非 200(404/5xx 等)→ 静默跳过
103
- const json = await res.json();
104
- const tags = parseDistTags(json);
105
- if (!tags) return null;
106
- return {
107
- tags,
108
- releaseNotesByVersion: parseReleaseNotesByVersion(json),
109
- };
106
+ if (!res.ok) {
107
+ await res.body?.cancel();
108
+ return null;
109
+ }
110
+ return await res.json();
110
111
  } catch {
111
- return null; // AbortError(超时)/ fetch failed(离线)/ JSON 解析失败 → 静默
112
+ return null;
112
113
  } finally {
113
114
  clearTimeout(timer);
114
115
  }
115
116
  }
116
117
 
117
118
  /**
118
- * 取 npm 上 flower-trellis 的 dist-tags;任何失败(离线/超时/非 200/解析异常)一律
119
- * 返回 null —— 调用方据此「拿不到就当没这回事」继续主流程。
120
- *
121
- * 用 AbortController 给内置 fetch 加超时,finally 清除定时器防句柄泄漏。
122
- * @returns {Promise<{latest:string|null,beta:string|null}|null>} 可用 dist-tags,或失败时 null
119
+ * 读取完整版本 metadata;仅在需要发布说明时调用。
120
+ * @param {{deadline?:number,timeoutMs?:number,fetchImpl?:Function,abortController?:AbortController}} [options] 共享网络预算
121
+ * @returns {Promise<{tags:object,releaseNotesByVersion:object}|null>} 版本与发布说明
122
+ */
123
+ export async function fetchPackageUpdateMetadata(options = {}) {
124
+ const json = await fetchRegistryJson(PKG, options);
125
+ const tags = parseDistTags(json);
126
+ return tags ? { tags, releaseNotesByVersion: parseReleaseNotesByVersion(json) } : null;
127
+ }
128
+
129
+ /**
130
+ * 仅查询 npm dist-tags,避免无更新时下载全部历史版本。
131
+ * @param {{deadline?:number,timeoutMs?:number,fetchImpl?:Function,abortController?:AbortController}} [options] 共享网络预算
132
+ * @returns {Promise<{latest:string|null,beta:string|null}|null>} 版本标签或降级空值
123
133
  */
124
- export async function fetchPackageDistTags() {
125
- const metadata = await fetchPackageUpdateMetadata();
126
- return metadata?.tags ?? null;
134
+ export async function fetchPackageDistTags(options = {}) {
135
+ const json = await fetchRegistryJson(`-/package/${PKG}/dist-tags`, options);
136
+ return parseDistTags({ "dist-tags": json });
137
+ }
138
+
139
+ /**
140
+ * 为一次检查创建按需读取器;标签与摘要共享预算,完整 metadata 至多读取一次。
141
+ * @param {{fetchMetadata?:Function,fetchTags?:Function,timeoutMs?:number,fetchImpl?:Function}} [options] 请求替身与预算
142
+ * @returns {{readTags:Function,readMetadata:Function}} 延迟执行的读取接口
143
+ */
144
+ export function createUpdateMetadataReader(options = {}) {
145
+ let deadline;
146
+ let metadata;
147
+ const abortController = new AbortController();
148
+ const requestOptions = () => {
149
+ deadline ??= performance.now() + (options.timeoutMs ?? TIMEOUT_MS);
150
+ return { deadline, fetchImpl: options.fetchImpl, abortController };
151
+ };
152
+ const readMetadata = () => {
153
+ metadata ??= timeOperation("版本检查", "读取发布说明", () => Promise.resolve().then(() => (
154
+ (options.fetchMetadata || fetchPackageUpdateMetadata)(requestOptions())
155
+ )).catch(() => null));
156
+ return metadata;
157
+ };
158
+ return {
159
+ readMetadata,
160
+ async readTags() {
161
+ // 保留已有完整 metadata 注入点;生产默认始终使用独立的轻量 endpoint。
162
+ if (options.fetchMetadata && !options.fetchTags) return (await readMetadata())?.tags ?? null;
163
+ try {
164
+ return await timeOperation("版本检查", "读取版本标签", () => (options.fetchTags || fetchPackageDistTags)(requestOptions()));
165
+ } catch {
166
+ return null;
167
+ }
168
+ },
169
+ };
127
170
  }
128
171
 
129
172
  /**
130
173
  * 取 npm 上 flower-trellis 的 latest 版本号。
131
174
  *
132
- * 保留这个导出是为了兼容已有调用方;新逻辑应优先使用 `fetchPackageUpdateMetadata()`。
175
+ * 保留这个导出是为了兼容已有调用方;新逻辑使用按需读取器共享标签与摘要的网络预算。
133
176
  * @returns {Promise<string|null>} latest 版本号,或失败时 null
134
177
  */
135
178
  export async function fetchLatestVersion() {
@@ -422,7 +465,6 @@ function rememberRemoteTags(target, tags, status, releaseNotes = null) {
422
465
  lastRemote: tags,
423
466
  lastStatus: status,
424
467
  lastErrorCode: null,
425
- lastReleaseNotes: null,
426
468
  };
427
469
  if (releaseNotes && !releaseNotes.unavailable) {
428
470
  patch.lastReleaseNotes = releaseNotes;
@@ -450,10 +492,30 @@ function rememberRemoteTags(target, tags, status, releaseNotes = null) {
450
492
  *
451
493
  * @param {object} ctx cli-args.js parseCliArgs() 产出的上下文(用到 updateCheck / passthrough)
452
494
  * @param {string} commandLabel 当前命令名,用于「请重新运行 ft <command>」文案(如 "init"/"update")
453
- * @param {{fetchMetadata?:typeof fetchPackageUpdateMetadata,report?:typeof reportTelemetry}} [options] 测试替换项
495
+ * @param {{fetchMetadata?:Function,fetchTags?:Function,fetchImpl?:Function,timeoutMs?:number,report?:Function,confirm?:Function,install?:Function}} [options] 测试替换项
454
496
  * @returns {Promise<void>} 注意:用户确认升级且成功时本函数会直接退出进程,不返回
455
497
  */
456
498
  export async function checkForUpdate(ctx, commandLabel, options = {}) {
499
+ const finish = beginOperationTiming(commandLabel, "版本检查(含确认等待)");
500
+ try {
501
+ return await executeCheckForUpdate(ctx, commandLabel, options, finish);
502
+ } catch (error) {
503
+ finish(false);
504
+ throw error;
505
+ } finally {
506
+ finish();
507
+ }
508
+ }
509
+
510
+ /**
511
+ * 执行版本提示与可选升级;直接退出前必须先冻结诊断阶段。
512
+ * @param {object} ctx 命令上下文
513
+ * @param {string} commandLabel 命令名
514
+ * @param {object} options 测试替身
515
+ * @param {Function} finish 版本检查阶段的幂等结束函数
516
+ * @returns {Promise<void>} 未升级退出时返回
517
+ */
518
+ async function executeCheckForUpdate(ctx, commandLabel, options, finish) {
457
519
  // 1. 关闭开关:显式 flag 或环境变量
458
520
  if (ctx.updateCheck === false || process.env.FLOWER_NO_UPDATE_CHECK) return;
459
521
  const updateCheck = readUpdateCheck(ctx.target);
@@ -461,35 +523,37 @@ export async function checkForUpdate(ctx, commandLabel, options = {}) {
461
523
  // 2. npx 本就是最新版,跳过(连通知都不打,避免误导)
462
524
  if (isRunningViaNpx(import.meta.url)) return;
463
525
 
464
- // 3. 尽力而为取 dist-tags;拿不到就静默退出
465
- const fetchMetadata = options.fetchMetadata || fetchPackageUpdateMetadata;
526
+ const reader = createUpdateMetadataReader(options);
527
+ const fromCache = isRemoteCacheFresh(updateCheck);
466
528
  const report = options.report || reportTelemetry;
467
- const [metadata] = await Promise.all([
468
- fetchMetadata(),
469
- Promise.resolve()
470
- .then(() => report(ctx.target, "version_check"))
471
- .catch(() => null),
529
+ if (!fromCache) console.log(" · 正在检查 Flower 版本");
530
+ const [tags] = fromCache ? [updateCheck.lastRemote] : await Promise.all([
531
+ reader.readTags(),
532
+ Promise.resolve().then(() => report(ctx.target, "version_check")).catch(() => null),
472
533
  ]);
473
- if (!metadata) return;
474
- const tags = metadata.tags;
534
+ if (!tags) return;
475
535
 
476
- // 4. 根据本地版本通道生成推荐;无推荐时不打扰
477
536
  const current = flowerVersion();
478
537
  const recommendation = getUpdateRecommendation(current, tags);
479
- const releaseNotes = recommendation
480
- ? buildReleaseNotesSummary(metadata.releaseNotesByVersion, {
481
- from: current,
482
- to: recommendation.version,
483
- channel: recommendation.tag,
484
- reason: "update_available",
485
- })
486
- : null;
487
- rememberRemoteTags(
488
- ctx.target,
489
- tags,
490
- recommendation ? "update_available" : "up_to_date",
491
- releaseNotes,
492
- );
538
+ const range = recommendation ? {
539
+ from: current,
540
+ to: recommendation.version,
541
+ channel: recommendation.tag,
542
+ reason: "update_available",
543
+ } : null;
544
+ const releaseNotes = cachedReleaseNotes(updateCheck, range) || (range
545
+ ? buildReleaseNotesSummary((await reader.readMetadata())?.releaseNotesByVersion, range)
546
+ : null);
547
+ if (!fromCache) {
548
+ rememberRemoteTags(ctx.target, tags, recommendation ? "update_available" : "up_to_date", releaseNotes);
549
+ } else if (releaseNotes && !releaseNotes.unavailable) {
550
+ // 摘要补拉不能把旧标签伪装成刚确认的远端版本。
551
+ try {
552
+ if (readManifest(ctx.target) || new ProjectStore(ctx.target).readLock()) {
553
+ writeUpdateCheck(ctx.target, { lastReleaseNotes: releaseNotes });
554
+ }
555
+ } catch { /* 缓存失败不阻断更新。 */ }
556
+ }
493
557
  if (!recommendation) return;
494
558
 
495
559
  // 5. 打印发现新版本通知(粉色品牌色,与 banner 一致)
@@ -511,7 +575,7 @@ export async function checkForUpdate(ctx, commandLabel, options = {}) {
511
575
  }
512
576
 
513
577
  // 7. 交互:询问是否升级(@inquirer/confirm,返回 boolean)
514
- const doUpgrade = await confirm({
578
+ const doUpgrade = await (options.confirm || confirm)({
515
579
  message: `是否现在升级到 ${recommendation.version}(${recommendation.tag})?(升级后需重新运行命令)`,
516
580
  default: true,
517
581
  });
@@ -521,11 +585,16 @@ export async function checkForUpdate(ctx, commandLabel, options = {}) {
521
585
  }
522
586
 
523
587
  // 8. 执行全局升级。失败(含 EACCES 权限问题、npm 不存在)不自行提权,降级为打印手动命令
524
- const res = installFlowerVersion(recommendation.version);
588
+ const res = timeOperation(commandLabel, "全局安装(含依赖与postinstall)", () => (
589
+ (options.install || installFlowerVersion)(recommendation.version)
590
+ ));
525
591
  if (res.status === 0) {
526
592
  console.log(`\n ✓ 已升级到 ${recommendation.version}(${recommendation.tag})`);
527
593
  console.log(` · 请重新运行 ft ${commandLabel} 以使用新版本`);
528
594
  console.log(" · 强化包随版本更新,升级后可 ft update 重新叠加到现有项目");
595
+ // process.exit 不会展开调用者的 finally;成功升级的旧进程退出前先结束阶段与父总计。
596
+ finish();
597
+ ctx.finishUpdateTiming?.();
529
598
  // 当前进程内存里仍是旧代码,必须退出由用户重跑新版本(不做 re-exec,规避权限/平台/进程态坑)
530
599
  process.exit(0);
531
600
  } else {