flower-trellis 0.4.5 → 0.4.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.
@@ -42,6 +42,12 @@ Before Phase 1.4 `task.py start`, use `trellis-task-brief` to refresh `<task>/br
42
42
 
43
43
  Before the first implement route, restate existing `<task>/brief.md` in chat. If missing, read task artifacts and suggest backfilling brief; do not invent one from memory.
44
44
 
45
+ #### Flower Update Confirmation
46
+
47
+ If `<flower-update>` has `priority: blocking_confirmation_required`, handle it first: briefly show `release_notes` when present, show `recommended_command`, and ask before running it.
48
+
49
+ If `<flower-update-result>` requests `run_trellis_push_confirmation`, enter `trellis-push` planning with update changes as default candidates; still require file-list and commit-message confirmation.
50
+
45
51
  #### Active Task Scope Guard
46
52
 
47
53
  When a session already has an active task, do not treat unrelated new implementation
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-07-07T10:09:11.758Z",
2
+ "syncedAt": "2026-07-07T12:53:36.169Z",
3
3
  "syncedFrom": "vendor/skill-garden",
4
- "sourceCommit": "5fdebe60e923c91ba912461b99413b5c8e11a38c",
4
+ "sourceCommit": "363dd9b54506b6cc67415e2bf76a856b743b7534",
5
5
  "common": {
6
6
  "codexSkills": [
7
7
  "craft-rpa",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flower-trellis",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,11 @@
32
32
  "release": "node scripts/check-snapshot.mjs && commit-and-tag-version",
33
33
  "release:dry": "commit-and-tag-version --dry-run"
34
34
  },
35
+ "commit-and-tag-version": {
36
+ "scripts": {
37
+ "postchangelog": "node scripts/write-release-notes-metadata.mjs"
38
+ }
39
+ },
35
40
  "keywords": [
36
41
  "trellis",
37
42
  "skill-garden",
@@ -51,5 +56,11 @@
51
56
  "license": "MIT",
52
57
  "devDependencies": {
53
58
  "commit-and-tag-version": "^12.7.3"
59
+ },
60
+ "flowerReleaseNotes": {
61
+ "version": "0.4.6",
62
+ "source": "CHANGELOG.md",
63
+ "body": "### ✨ 新功能 Features\n\n* **update:** 注入跨版本更新摘要并联动 push 确认 ([bbdb23c](https://github.com/SilentFlower/flower-trellis/commit/bbdb23c33eac9948235c65f6be6f18764f90a88d))",
64
+ "truncated": false
54
65
  }
55
66
  }
@@ -75,6 +75,55 @@ def _run_self_check(project_dir: Path) -> dict | None:
75
75
  return data if isinstance(data, dict) else None
76
76
 
77
77
 
78
+ def _json_bool(value: object) -> str:
79
+ """把布尔值格式化成 JSON 风格小写文本。"""
80
+ return json.dumps(bool(value), ensure_ascii=False)
81
+
82
+
83
+ def _release_notes_lines(data: dict) -> list[str]:
84
+ """把 self-check 的 releaseNotes 摘要格式化为短字段。"""
85
+ notes = data.get("releaseNotes")
86
+ if not isinstance(notes, dict):
87
+ return []
88
+ versions = notes.get("versions") or []
89
+ if not isinstance(versions, list):
90
+ versions = []
91
+ safe_versions = []
92
+ for entry in versions:
93
+ if not isinstance(entry, dict):
94
+ continue
95
+ version = entry.get("version")
96
+ body = entry.get("body")
97
+ if isinstance(version, str) and isinstance(body, str) and body.strip():
98
+ safe_versions.append(
99
+ {
100
+ "version": version,
101
+ "body": body,
102
+ "truncated": bool(entry.get("truncated")),
103
+ }
104
+ )
105
+
106
+ lines: list[str] = []
107
+ note_range = notes.get("range") or {}
108
+ if isinstance(note_range, dict) and (note_range.get("from") or note_range.get("to")):
109
+ range_text = f"{note_range.get('from')} -> {note_range.get('to')}"
110
+ if note_range.get("channel"):
111
+ range_text += f" ({note_range.get('channel')})"
112
+ if note_range.get("reason"):
113
+ range_text += f" reason={note_range.get('reason')}"
114
+ lines.append(f"release_notes_range: {range_text}")
115
+ if safe_versions:
116
+ lines.append(
117
+ "release_notes: "
118
+ + json.dumps(safe_versions, ensure_ascii=False, separators=(",", ":"))
119
+ )
120
+ elif notes.get("unavailable"):
121
+ lines.append("release_notes_unavailable: true")
122
+ lines.append(f"release_notes_truncated: {_json_bool(notes.get('truncated'))}")
123
+ lines.append(f"release_notes_more_versions: {_json_bool(notes.get('moreVersions'))}")
124
+ return lines
125
+
126
+
78
127
  def _format_context(data: dict) -> str:
79
128
  """把 self-check JSON 转成给 AI 读取的短上下文块。"""
80
129
  current = data.get("current") or {}
@@ -102,6 +151,7 @@ def _format_context(data: dict) -> str:
102
151
  lines.append(f"remote: {json.dumps(remote.get('tags'), ensure_ascii=False)}")
103
152
  if remote.get("errorCode"):
104
153
  lines.append(f"remote_error_code: {remote.get('errorCode')}")
154
+ lines.extend(_release_notes_lines(data))
105
155
  command = (data.get("commands") or {}).get("recommended") or ai.get("command")
106
156
  if command:
107
157
  lines.append(f"recommended_command: {command}")
@@ -110,7 +160,9 @@ def _format_context(data: dict) -> str:
110
160
  if ai.get("mode"):
111
161
  lines.append(f"ai_mode: {ai.get('mode')}")
112
162
  if ai.get("mode") == "ask":
113
- lines.append("ai_required_action: 必须先向用户提出明确确认问题;用户确认前禁止执行 recommended_command。")
163
+ lines.append(
164
+ "ai_required_action: 必须先向用户展示 release_notes 摘要和 recommended_command,再提出明确确认问题;用户确认前禁止执行 recommended_command。"
165
+ )
114
166
  if ai.get("instruction"):
115
167
  lines.append(f"ai_instruction: {ai.get('instruction')}")
116
168
  lines.append("</flower-update>")
@@ -121,7 +173,11 @@ def _system_message(data: dict) -> str:
121
173
  """生成 Codex / Claude Code 更容易注意到的短系统提示。"""
122
174
  ai = data.get("ai") or {}
123
175
  command = (data.get("commands") or {}).get("recommended") or ai.get("command")
176
+ notes = data.get("releaseNotes") or {}
177
+ has_notes = isinstance(notes, dict) and bool(notes.get("versions"))
124
178
  if ai.get("mode") == "ask" and command:
179
+ if has_notes:
180
+ return "flower-trellis 发现可执行更新;必须先展示更新摘要并询问用户是否执行 recommended_command,确认前禁止运行。"
125
181
  return "flower-trellis 发现可执行更新;必须先询问用户是否执行 recommended_command,确认前禁止运行。"
126
182
  if command:
127
183
  return "flower-trellis 发现可执行更新;已注入 recommended_command。"
@@ -24,6 +24,80 @@ function runCommand(command, args, cwd, failureMessage) {
24
24
  }
25
25
  }
26
26
 
27
+ /** 读取目标 git 工作区变动数量,只作为 self-update 后续动作提示。 */
28
+ function gitDirtySummary(target) {
29
+ const common = {
30
+ cwd: target,
31
+ encoding: "utf8",
32
+ shell: process.platform === "win32",
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ timeout: 1500,
35
+ };
36
+ const root = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], common);
37
+ if (root.error || root.status !== 0 || String(root.stdout || "").trim() !== "true") {
38
+ return {
39
+ checked: false,
40
+ reason: "not_git_repo",
41
+ dirtyCount: 0,
42
+ changedFilesDetected: false,
43
+ files: [],
44
+ };
45
+ }
46
+ const status = spawnSync("git", ["status", "--porcelain"], common);
47
+ if (status.error || status.status !== 0) {
48
+ return {
49
+ checked: false,
50
+ reason: "git_status_failed",
51
+ dirtyCount: 0,
52
+ changedFilesDetected: false,
53
+ files: [],
54
+ };
55
+ }
56
+ const files = String(status.stdout || "")
57
+ .split(/\r?\n/)
58
+ .map((line) => line.trim())
59
+ .filter(Boolean);
60
+ return {
61
+ checked: true,
62
+ reason: null,
63
+ dirtyCount: files.length,
64
+ changedFilesDetected: files.length > 0,
65
+ files: files.slice(0, 20),
66
+ };
67
+ }
68
+
69
+ /** 打印 release notes 预览行。 */
70
+ function printReleaseNotesPreview(releaseNotes) {
71
+ if (!releaseNotes) return;
72
+ const versions = Array.isArray(releaseNotes.versions) ? releaseNotes.versions : [];
73
+ if (!versions.length) {
74
+ if (releaseNotes.unavailable) console.log(" · 更新内容:未获取到可用 release notes");
75
+ return;
76
+ }
77
+ console.log(" · 更新内容:");
78
+ for (const entry of versions) {
79
+ const suffix = entry.truncated ? " (已截断)" : "";
80
+ console.log(` - ${entry.version}${suffix}:${entry.body}`);
81
+ }
82
+ if (releaseNotes.moreVersions) {
83
+ console.log(" - 还有更多版本变更未展示");
84
+ }
85
+ }
86
+
87
+ /** 打印结构化 self-update 结果块。 */
88
+ function printFlowerUpdateResult(fields) {
89
+ console.log("<flower-update-result>");
90
+ for (const [key, value] of Object.entries(fields)) {
91
+ if (value === undefined) continue;
92
+ if (typeof value === "object" && value !== null) {
93
+ console.log(`${key}: ${JSON.stringify(value)}`);
94
+ } else {
95
+ console.log(`${key}: ${value}`);
96
+ }
97
+ }
98
+ console.log("</flower-update-result>");
99
+ }
100
+
27
101
  /**
28
102
  * flower-trellis self-update:执行受控自更新与项目重叠加。
29
103
  *
@@ -73,7 +147,16 @@ export async function selfUpdate(ctx) {
73
147
  console.log(
74
148
  ` · 安全检查:${effectiveSafety.safe ? "通过" : `需确认(${effectiveSafety.reasons.join(", ") || "无可用结果"})`}`,
75
149
  );
150
+ printReleaseNotesPreview(check.releaseNotes);
76
151
  console.log(` · 写入:否`);
152
+ printFlowerUpdateResult({
153
+ status: "dry_run",
154
+ target: ctx.target,
155
+ write: false,
156
+ current_status: check.status,
157
+ post_action_preview: "run_trellis_push_after_real_update",
158
+ release_notes: check.releaseNotes || null,
159
+ });
77
160
  return;
78
161
  }
79
162
 
@@ -104,5 +187,19 @@ export async function selfUpdate(ctx) {
104
187
  "目标项目重叠加失败,请手动运行:" + projectUpdateCommand(ctx.target, forwarded),
105
188
  );
106
189
 
190
+ const dirty = gitDirtySummary(ctx.target);
107
191
  console.log(`\n🌸 flower-trellis self-update 完成 → ${ctx.target}`);
192
+ printFlowerUpdateResult({
193
+ status: "completed",
194
+ target: ctx.target,
195
+ write: true,
196
+ git_dirty_count: dirty.dirtyCount,
197
+ changed_files_detected: dirty.changedFilesDetected,
198
+ changed_files_sample: dirty.files,
199
+ git_check_reason: dirty.reason,
200
+ post_action: "run_trellis_push_confirmation",
201
+ release_notes: check.releaseNotes || null,
202
+ ai_instruction:
203
+ "汇总本次升级产生的文件变动,进入 trellis-push 执行计划,展示具体文件列表和 commit message 后等待用户确认。",
204
+ });
108
205
  }
@@ -18,10 +18,61 @@ const DEFAULT_UPDATE_CHECK = {
18
18
  intervalHours: 8,
19
19
  lastCheckedAt: null,
20
20
  lastRemote: null,
21
+ lastReleaseNotes: null,
21
22
  lastStatus: null,
22
23
  lastErrorCode: null,
23
24
  };
24
25
 
26
+ /**
27
+ * 归一化 release notes 范围字段。
28
+ *
29
+ * @param {object|null|undefined} value 原始 range 字段
30
+ * @returns {{from:string|null,to:string|null,channel:string|null,reason:string|null}} 归一化 range
31
+ */
32
+ function normalizeReleaseNotesRange(value) {
33
+ const raw = value && typeof value === "object" ? value : {};
34
+ return {
35
+ from: typeof raw.from === "string" ? raw.from : null,
36
+ to: typeof raw.to === "string" ? raw.to : null,
37
+ channel: typeof raw.channel === "string" ? raw.channel : null,
38
+ reason: typeof raw.reason === "string" ? raw.reason : null,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * 归一化最近一次可用 release notes 摘要。
44
+ *
45
+ * @param {object|null|undefined} value 原始 lastReleaseNotes 字段
46
+ * @returns {{source:string,range:object,versions:Array<{version:string,body:string,truncated:boolean}>,truncated:boolean,moreVersions:boolean,unavailable:boolean}|null} 归一化摘要
47
+ */
48
+ function normalizeLastReleaseNotes(value) {
49
+ if (!value || typeof value !== "object") return null;
50
+ const versions = Array.isArray(value.versions)
51
+ ? value.versions
52
+ .map((entry) => {
53
+ if (!entry || typeof entry !== "object") return null;
54
+ const version = typeof entry.version === "string" ? entry.version : null;
55
+ const body = typeof entry.body === "string" ? entry.body : null;
56
+ if (!version || !body) return null;
57
+ return {
58
+ version,
59
+ body,
60
+ truncated: entry.truncated === true,
61
+ };
62
+ })
63
+ .filter(Boolean)
64
+ : [];
65
+ if (!versions.length && value.unavailable !== true) return null;
66
+ return {
67
+ source: typeof value.source === "string" ? value.source : "npm-metadata",
68
+ range: normalizeReleaseNotesRange(value.range),
69
+ versions,
70
+ truncated: value.truncated === true,
71
+ moreVersions: value.moreVersions === true,
72
+ unavailable: value.unavailable === true,
73
+ };
74
+ }
75
+
25
76
  /** manifest 文件的绝对路径。 */
26
77
  export function manifestPath(target) {
27
78
  return path.join(target, MANIFEST_REL);
@@ -42,7 +93,7 @@ export function readManifest(target) {
42
93
  * 用户策略字段启用保守默认值;缓存字段只保留结构化摘要,避免把网络错误细节写入项目。
43
94
  *
44
95
  * @param {object|null|undefined} value 原始 updateCheck 字段
45
- * @returns {{enabled:boolean,policy:string,intervalHours:number,lastCheckedAt:string|null,lastRemote:object|null,lastStatus:string|null,lastErrorCode:string|null}} 归一化后的 updateCheck
96
+ * @returns {{enabled:boolean,policy:string,intervalHours:number,lastCheckedAt:string|null,lastRemote:object|null,lastReleaseNotes:object|null,lastStatus:string|null,lastErrorCode:string|null}} 归一化后的 updateCheck
46
97
  */
47
98
  export function normalizeUpdateCheck(value) {
48
99
  const raw = value && typeof value === "object" ? value : {};
@@ -64,6 +115,7 @@ export function normalizeUpdateCheck(value) {
64
115
  intervalHours,
65
116
  lastCheckedAt: typeof raw.lastCheckedAt === "string" ? raw.lastCheckedAt : null,
66
117
  lastRemote,
118
+ lastReleaseNotes: normalizeLastReleaseNotes(raw.lastReleaseNotes),
67
119
  lastStatus: typeof raw.lastStatus === "string" ? raw.lastStatus : null,
68
120
  lastErrorCode: typeof raw.lastErrorCode === "string" ? raw.lastErrorCode : null,
69
121
  };
@@ -2,7 +2,12 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readManifest, readUpdateCheck, writeUpdateCheck } from "./manifest.js";
5
- import { fetchPackageDistTags, getUpdateRecommendation } from "./update-check.js";
5
+ import {
6
+ buildReleaseNotesSummary,
7
+ fetchPackageUpdateMetadata,
8
+ getUpdateRecommendation,
9
+ isPrerelease,
10
+ } from "./update-check.js";
6
11
  import { isRunningViaNpx } from "./runtime-env.js";
7
12
  import { flowerVersion, trellisVersion } from "./versions.js";
8
13
 
@@ -36,6 +41,74 @@ function isRemoteCacheFresh(updateCheck, now = new Date()) {
36
41
  return now.getTime() - checkedAt < updateCheck.intervalHours * 60 * 60 * 1000;
37
42
  }
38
43
 
44
+ /**
45
+ * 判断缓存的 release notes 是否匹配本次检查范围。
46
+ *
47
+ * @param {object} updateCheck 归一化 updateCheck 配置
48
+ * @param {{from:string|null,to:string|null,channel:string,reason:string}|null} range 本次期望范围
49
+ * @returns {object|null} 可复用的缓存摘要
50
+ */
51
+ function cachedReleaseNotes(updateCheck, range) {
52
+ const cached = updateCheck.lastReleaseNotes;
53
+ if (!cached || !range || cached.unavailable) return null;
54
+ const cachedRange = cached.range || {};
55
+ if (
56
+ cachedRange.from !== range.from ||
57
+ cachedRange.to !== range.to ||
58
+ cachedRange.channel !== range.channel ||
59
+ cachedRange.reason !== range.reason
60
+ ) {
61
+ return null;
62
+ }
63
+ return Array.isArray(cached.versions) && cached.versions.length ? cached : null;
64
+ }
65
+
66
+ /**
67
+ * 构造新版升级 release notes 范围。
68
+ *
69
+ * @param {string} currentFlower 当前 flower 版本
70
+ * @param {{version:string,tag:string}|null} recommendation 升级推荐
71
+ * @returns {{from:string,to:string,channel:string,reason:string}|null} release notes 范围
72
+ */
73
+ function updateReleaseNotesRange(currentFlower, recommendation) {
74
+ if (!recommendation?.version) return null;
75
+ return {
76
+ from: currentFlower,
77
+ to: recommendation.version,
78
+ channel: recommendation.tag,
79
+ reason: "update_available",
80
+ };
81
+ }
82
+
83
+ /**
84
+ * 构造项目追平 release notes 范围。
85
+ *
86
+ * @param {string|null} projectFlower 项目 manifest 记录的 flower 版本
87
+ * @param {string} currentFlower 当前 flower 版本
88
+ * @returns {{from:string,to:string,channel:string,reason:string}|null} release notes 范围
89
+ */
90
+ function projectReleaseNotesRange(projectFlower, currentFlower) {
91
+ if (!projectFlower || !currentFlower) return null;
92
+ return {
93
+ from: projectFlower,
94
+ to: currentFlower,
95
+ channel: isPrerelease(currentFlower) ? "beta" : "latest",
96
+ reason: "project_out_of_sync",
97
+ };
98
+ }
99
+
100
+ /**
101
+ * 从 registry metadata 构造 release notes 摘要。
102
+ *
103
+ * @param {object|null} metadata registry metadata
104
+ * @param {object|null} range release notes 范围
105
+ * @returns {object|null} release notes 摘要
106
+ */
107
+ function releaseNotesFromMetadata(metadata, range) {
108
+ if (!range) return null;
109
+ return buildReleaseNotesSummary(metadata?.releaseNotesByVersion, range);
110
+ }
111
+
39
112
  /** 检查目标目录 git 工作区是否 clean。 */
40
113
  function gitSafety(target) {
41
114
  const common = {
@@ -184,7 +257,7 @@ function withAction(target, policy, result, command) {
184
257
  }
185
258
 
186
259
  /** 生成项目重叠加建议结果。 */
187
- function projectOutOfSyncResult(base, target, remotePatch = {}) {
260
+ function projectOutOfSyncResult(base, target, remotePatch = {}, releaseNotes = null) {
188
261
  const command = selfUpdateCommand(target, { projectOnly: true });
189
262
  return withAction(
190
263
  target,
@@ -198,6 +271,7 @@ function projectOutOfSyncResult(base, target, remotePatch = {}) {
198
271
  recommended: command,
199
272
  projectUpdate: projectUpdateCommand(target),
200
273
  },
274
+ releaseNotes,
201
275
  },
202
276
  command,
203
277
  );
@@ -276,6 +350,7 @@ export async function buildSelfCheck(target, options = {}) {
276
350
  errorCode: null,
277
351
  },
278
352
  recommendation: null,
353
+ releaseNotes: null,
279
354
  commands: {},
280
355
  safety: null,
281
356
  ai: null,
@@ -297,6 +372,7 @@ export async function buildSelfCheck(target, options = {}) {
297
372
  const recommendation = getUpdateRecommendation(currentFlower, tags);
298
373
  if (recommendation) {
299
374
  const command = selfUpdateCommand(absoluteTarget);
375
+ const releaseNotesRange = updateReleaseNotesRange(currentFlower, recommendation);
300
376
  return withAction(
301
377
  absoluteTarget,
302
378
  updateCheck.policy,
@@ -311,16 +387,18 @@ export async function buildSelfCheck(target, options = {}) {
311
387
  npm: recommendation.command,
312
388
  projectUpdate: projectUpdateCommand(absoluteTarget),
313
389
  },
390
+ releaseNotes: cachedReleaseNotes(updateCheck, releaseNotesRange),
314
391
  },
315
392
  command,
316
393
  );
317
394
  }
318
395
  if (projectOutOfSync) {
396
+ const releaseNotesRange = projectReleaseNotesRange(projectFlower, currentFlower);
319
397
  return projectOutOfSyncResult(base, absoluteTarget, {
320
398
  tags,
321
399
  fromCache: true,
322
400
  skipped: true,
323
- });
401
+ }, cachedReleaseNotes(updateCheck, releaseNotesRange));
324
402
  }
325
403
  return {
326
404
  ...base,
@@ -330,8 +408,8 @@ export async function buildSelfCheck(target, options = {}) {
330
408
  };
331
409
  }
332
410
 
333
- tags = await fetchPackageDistTags();
334
- if (!tags) {
411
+ const metadata = await fetchPackageUpdateMetadata();
412
+ if (!metadata) {
335
413
  if (writeCache && manifest) {
336
414
  writeUpdateCheck(absoluteTarget, {
337
415
  lastStatus: "offline",
@@ -340,7 +418,13 @@ export async function buildSelfCheck(target, options = {}) {
340
418
  }
341
419
  const remotePatch = { tags: updateCheck.lastRemote, errorCode: "fetch_failed" };
342
420
  if (projectOutOfSync) {
343
- return projectOutOfSyncResult(base, absoluteTarget, remotePatch);
421
+ const releaseNotesRange = projectReleaseNotesRange(projectFlower, currentFlower);
422
+ return projectOutOfSyncResult(
423
+ base,
424
+ absoluteTarget,
425
+ remotePatch,
426
+ cachedReleaseNotes(updateCheck, releaseNotesRange),
427
+ );
344
428
  }
345
429
  return {
346
430
  ...base,
@@ -350,12 +434,18 @@ export async function buildSelfCheck(target, options = {}) {
350
434
  };
351
435
  }
352
436
 
437
+ tags = metadata.tags;
353
438
  const recommendation = getUpdateRecommendation(currentFlower, tags);
354
439
  const remoteStatus = recommendation ? "update_available" : "up_to_date";
440
+ const releaseNotesRange = recommendation
441
+ ? updateReleaseNotesRange(currentFlower, recommendation)
442
+ : projectReleaseNotesRange(projectFlower, currentFlower);
443
+ const releaseNotes = releaseNotesFromMetadata(metadata, releaseNotesRange);
355
444
  if (writeCache && manifest) {
356
445
  writeUpdateCheck(absoluteTarget, {
357
446
  lastCheckedAt: now.toISOString(),
358
447
  lastRemote: tags,
448
+ lastReleaseNotes: releaseNotes && !releaseNotes.unavailable ? releaseNotes : null,
359
449
  lastStatus: remoteStatus,
360
450
  lastErrorCode: null,
361
451
  });
@@ -363,7 +453,7 @@ export async function buildSelfCheck(target, options = {}) {
363
453
 
364
454
  if (!recommendation) {
365
455
  if (projectOutOfSync) {
366
- return projectOutOfSyncResult(base, absoluteTarget, { tags });
456
+ return projectOutOfSyncResult(base, absoluteTarget, { tags }, releaseNotes);
367
457
  }
368
458
  return {
369
459
  ...base,
@@ -386,6 +476,7 @@ export async function buildSelfCheck(target, options = {}) {
386
476
  npm: recommendation.command,
387
477
  projectUpdate: projectUpdateCommand(absoluteTarget),
388
478
  },
479
+ releaseNotes,
389
480
  },
390
481
  command,
391
482
  );
@@ -19,15 +19,75 @@ const REGISTRY = "https://registry.npmjs.org";
19
19
  const PKG = "flower-trellis";
20
20
  /** 网络探测超时(毫秒)—— 启动检查有 hook 总预算兜底,5s 可降低 registry 偶发慢响应误判。 */
21
21
  const TIMEOUT_MS = 5000;
22
+ /** npm package metadata 中保存 flower 内部发布说明的字段名。 */
23
+ const RELEASE_NOTES_FIELD = "flowerReleaseNotes";
24
+ /** 单次注入最多展示的版本数。 */
25
+ const RELEASE_NOTES_MAX_VERSIONS = 5;
26
+ /** 单个版本发布说明最多保留的字符数。 */
27
+ const RELEASE_NOTES_MAX_VERSION_CHARS = 500;
28
+ /** 所有版本发布说明合计最多保留的字符数。 */
29
+ const RELEASE_NOTES_MAX_TOTAL_CHARS = 1600;
22
30
 
23
31
  /**
24
- * npm flower-trellis dist-tags;任何失败(离线/超时/非 200/解析异常)一律
25
- * 返回 null —— 调用方据此「拿不到就当没这回事」继续主流程。
32
+ * npm registry 根文档解析 dist-tags
26
33
  *
27
- * AbortController 给内置 fetch 加超时,finally 清除定时器防句柄泄漏。
28
- * @returns {Promise<{latest:string|null,beta:string|null}|null>} 可用 dist-tags,或失败时 null
34
+ * @param {object} json registry 根文档 JSON
35
+ * @returns {{latest:string|null,beta:string|null}|null} 可用 dist-tags
29
36
  */
30
- export async function fetchPackageDistTags() {
37
+ function parseDistTags(json) {
38
+ const tags = json && typeof json === "object" ? json["dist-tags"] : null;
39
+ const latest = typeof tags?.latest === "string" ? tags.latest : null;
40
+ const beta = typeof tags?.beta === "string" ? tags.beta : null;
41
+ return latest || beta ? { latest, beta } : null;
42
+ }
43
+
44
+ /**
45
+ * 解析单个版本的 flowerReleaseNotes metadata。
46
+ *
47
+ * @param {string} version registry versions 字典里的版本号
48
+ * @param {object} pkgVersion 对应版本 package metadata
49
+ * @returns {{version:string,body:string,truncated:boolean,source:string}|null} 发布说明 metadata
50
+ */
51
+ function parseReleaseNotesMetadata(version, pkgVersion) {
52
+ const raw = pkgVersion?.[RELEASE_NOTES_FIELD];
53
+ if (!raw || typeof raw !== "object") return null;
54
+ if (raw.version !== version) return null;
55
+ const body = typeof raw.body === "string" ? raw.body.trim() : "";
56
+ if (!body) return null;
57
+ return {
58
+ version,
59
+ body,
60
+ truncated: raw.truncated === true,
61
+ source: "npm-metadata",
62
+ };
63
+ }
64
+
65
+ /**
66
+ * 从 npm registry 根文档解析每个版本的发布说明 metadata。
67
+ *
68
+ * @param {object} json registry 根文档 JSON
69
+ * @returns {Record<string,{version:string,body:string,truncated:boolean,source:string}>} 按版本号索引的发布说明
70
+ */
71
+ function parseReleaseNotesByVersion(json) {
72
+ const versions = json && typeof json === "object" ? json.versions : null;
73
+ if (!versions || typeof versions !== "object") return {};
74
+ const notes = {};
75
+ for (const [version, pkgVersion] of Object.entries(versions)) {
76
+ const parsed = parseReleaseNotesMetadata(version, pkgVersion);
77
+ if (parsed) notes[version] = parsed;
78
+ }
79
+ return notes;
80
+ }
81
+
82
+ /**
83
+ * 取 npm 上 flower-trellis 的更新 metadata。
84
+ *
85
+ * 一次请求 registry 根文档,同时解析 dist-tags 与各版本的 flowerReleaseNotes。任何失败
86
+ * (离线/超时/非 200/解析异常)一律返回 null,调用方继续主流程。
87
+ *
88
+ * @returns {Promise<{tags:{latest:string|null,beta:string|null},releaseNotesByVersion:Record<string,{version:string,body:string,truncated:boolean,source:string}>}|null>} 更新 metadata
89
+ */
90
+ export async function fetchPackageUpdateMetadata() {
31
91
  const ac = new AbortController();
32
92
  const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
33
93
  try {
@@ -37,10 +97,12 @@ export async function fetchPackageDistTags() {
37
97
  });
38
98
  if (!res.ok) return null; // 非 200(404/5xx 等)→ 静默跳过
39
99
  const json = await res.json();
40
- const tags = json && typeof json === "object" ? json["dist-tags"] : null;
41
- const latest = typeof tags?.latest === "string" ? tags.latest : null;
42
- const beta = typeof tags?.beta === "string" ? tags.beta : null;
43
- return latest || beta ? { latest, beta } : null;
100
+ const tags = parseDistTags(json);
101
+ if (!tags) return null;
102
+ return {
103
+ tags,
104
+ releaseNotesByVersion: parseReleaseNotesByVersion(json),
105
+ };
44
106
  } catch {
45
107
  return null; // AbortError(超时)/ fetch failed(离线)/ JSON 解析失败 → 静默
46
108
  } finally {
@@ -48,10 +110,22 @@ export async function fetchPackageDistTags() {
48
110
  }
49
111
  }
50
112
 
113
+ /**
114
+ * 取 npm 上 flower-trellis 的 dist-tags;任何失败(离线/超时/非 200/解析异常)一律
115
+ * 返回 null —— 调用方据此「拿不到就当没这回事」继续主流程。
116
+ *
117
+ * 用 AbortController 给内置 fetch 加超时,finally 清除定时器防句柄泄漏。
118
+ * @returns {Promise<{latest:string|null,beta:string|null}|null>} 可用 dist-tags,或失败时 null
119
+ */
120
+ export async function fetchPackageDistTags() {
121
+ const metadata = await fetchPackageUpdateMetadata();
122
+ return metadata?.tags ?? null;
123
+ }
124
+
51
125
  /**
52
126
  * 取 npm 上 flower-trellis 的 latest 版本号。
53
127
  *
54
- * 保留这个导出是为了兼容已有调用方;新逻辑应优先使用 `fetchPackageDistTags()`。
128
+ * 保留这个导出是为了兼容已有调用方;新逻辑应优先使用 `fetchPackageUpdateMetadata()`。
55
129
  * @returns {Promise<string|null>} latest 版本号,或失败时 null
56
130
  */
57
131
  export async function fetchLatestVersion() {
@@ -147,6 +221,106 @@ export function compareVersions(a, b) {
147
221
  return comparePrerelease(av.prerelease, bv.prerelease);
148
222
  }
149
223
 
224
+ /**
225
+ * 判断版本是否属于目标更新通道。
226
+ *
227
+ * @param {string} version 版本号
228
+ * @param {"latest"|"beta"|string} channel 目标通道
229
+ * @returns {boolean}
230
+ */
231
+ function matchesReleaseNotesChannel(version, channel) {
232
+ if (channel === "beta") return isPrerelease(version);
233
+ return !isPrerelease(version);
234
+ }
235
+
236
+ /**
237
+ * 把单版本发布说明裁剪到注入上限。
238
+ *
239
+ * @param {{body:string,truncated?:boolean}} note 原始版本发布说明
240
+ * @param {number} remainingTotal 总字符剩余额度
241
+ * @returns {{body:string,truncated:boolean,used:number}|null} 裁剪后的发布说明
242
+ */
243
+ function limitReleaseNoteBody(note, remainingTotal) {
244
+ if (remainingTotal <= 0) return null;
245
+ let body = String(note.body || "").trim();
246
+ let truncated = note.truncated === true;
247
+ if (!body) return null;
248
+ if (body.length > RELEASE_NOTES_MAX_VERSION_CHARS) {
249
+ body = body.slice(0, RELEASE_NOTES_MAX_VERSION_CHARS).trimEnd();
250
+ truncated = true;
251
+ }
252
+ if (body.length > remainingTotal) {
253
+ body = body.slice(0, remainingTotal).trimEnd();
254
+ truncated = true;
255
+ }
256
+ if (!body) return null;
257
+ return { body, truncated, used: body.length };
258
+ }
259
+
260
+ /**
261
+ * 为 self-check / hook 构造跨版本发布说明摘要。
262
+ *
263
+ * @param {Record<string,{version:string,body:string,truncated:boolean,source:string}>|null|undefined} releaseNotesByVersion 按版本索引的 npm metadata
264
+ * @param {{from:string|null,to:string|null,channel:"latest"|"beta"|string,reason:string}} range 聚合范围
265
+ * @returns {{source:string,range:object,versions:Array<{version:string,body:string,truncated:boolean}>,truncated:boolean,moreVersions:boolean,unavailable:boolean}} 可注入的发布说明摘要
266
+ */
267
+ export function buildReleaseNotesSummary(releaseNotesByVersion, range) {
268
+ const summaryRange = {
269
+ from: range?.from ?? null,
270
+ to: range?.to ?? null,
271
+ channel: range?.channel ?? "latest",
272
+ reason: range?.reason ?? null,
273
+ };
274
+ const empty = {
275
+ source: "npm-metadata",
276
+ range: summaryRange,
277
+ versions: [],
278
+ truncated: false,
279
+ moreVersions: false,
280
+ unavailable: true,
281
+ };
282
+ if (!summaryRange.from || !summaryRange.to || !releaseNotesByVersion) return empty;
283
+
284
+ const candidates = Object.keys(releaseNotesByVersion)
285
+ .filter((version) => compareVersions(version, summaryRange.from) === 1)
286
+ .filter((version) => compareVersions(version, summaryRange.to) <= 0)
287
+ .filter((version) => matchesReleaseNotesChannel(version, summaryRange.channel))
288
+ .sort(compareVersions);
289
+
290
+ if (!candidates.length) return empty;
291
+
292
+ const limitedCandidates = candidates.slice(-RELEASE_NOTES_MAX_VERSIONS);
293
+ const versions = [];
294
+ let remainingTotal = RELEASE_NOTES_MAX_TOTAL_CHARS;
295
+ let truncated = false;
296
+ for (const version of limitedCandidates) {
297
+ const limited = limitReleaseNoteBody(releaseNotesByVersion[version], remainingTotal);
298
+ if (!limited) {
299
+ truncated = true;
300
+ break;
301
+ }
302
+ versions.push({
303
+ version,
304
+ body: limited.body,
305
+ truncated: limited.truncated,
306
+ });
307
+ remainingTotal -= limited.used;
308
+ if (limited.truncated) truncated = true;
309
+ }
310
+
311
+ const moreVersions =
312
+ candidates.length > limitedCandidates.length ||
313
+ versions.length < limitedCandidates.length;
314
+ return {
315
+ source: "npm-metadata",
316
+ range: summaryRange,
317
+ versions,
318
+ truncated,
319
+ moreVersions,
320
+ unavailable: versions.length === 0,
321
+ };
322
+ }
323
+
150
324
  /**
151
325
  * 根据当前版本和 npm dist-tags 生成升级推荐。
152
326
  *
@@ -175,15 +349,20 @@ export function getUpdateRecommendation(current, tags) {
175
349
  }
176
350
 
177
351
  /** 尽力而为刷新目标项目 manifest 里的远端探测缓存。 */
178
- function rememberRemoteTags(target, tags, status) {
352
+ function rememberRemoteTags(target, tags, status, releaseNotes = null) {
179
353
  try {
180
354
  if (!readManifest(target)) return;
181
- writeUpdateCheck(target, {
355
+ const patch = {
182
356
  lastCheckedAt: new Date().toISOString(),
183
357
  lastRemote: tags,
184
358
  lastStatus: status,
185
359
  lastErrorCode: null,
186
- });
360
+ lastReleaseNotes: null,
361
+ };
362
+ if (releaseNotes && !releaseNotes.unavailable) {
363
+ patch.lastReleaseNotes = releaseNotes;
364
+ }
365
+ writeUpdateCheck(target, patch);
187
366
  } catch {
188
367
  // 缓存写入只是优化后续启动提示,失败不能影响 init/update 主流程。
189
368
  }
@@ -217,13 +396,27 @@ export async function checkForUpdate(ctx, commandLabel) {
217
396
  if (isRunningViaNpx(import.meta.url)) return;
218
397
 
219
398
  // 3. 尽力而为取 dist-tags;拿不到就静默退出
220
- const tags = await fetchPackageDistTags();
221
- if (!tags) return;
399
+ const metadata = await fetchPackageUpdateMetadata();
400
+ if (!metadata) return;
401
+ const tags = metadata.tags;
222
402
 
223
403
  // 4. 根据本地版本通道生成推荐;无推荐时不打扰
224
404
  const current = flowerVersion();
225
405
  const recommendation = getUpdateRecommendation(current, tags);
226
- rememberRemoteTags(ctx.target, tags, recommendation ? "update_available" : "up_to_date");
406
+ const releaseNotes = recommendation
407
+ ? buildReleaseNotesSummary(metadata.releaseNotesByVersion, {
408
+ from: current,
409
+ to: recommendation.version,
410
+ channel: recommendation.tag,
411
+ reason: "update_available",
412
+ })
413
+ : null;
414
+ rememberRemoteTags(
415
+ ctx.target,
416
+ tags,
417
+ recommendation ? "update_available" : "up_to_date",
418
+ releaseNotes,
419
+ );
227
420
  if (!recommendation) return;
228
421
 
229
422
  // 5. 打印发现新版本通知(粉色品牌色,与 banner 一致)