flower-trellis 0.4.5-beta.3 → 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.
- package/enhancements/0.6/overrides/workflow.md +6 -0
- package/enhancements/MANIFEST.json +2 -2
- package/package.json +12 -1
- package/src/assets/flower_update_hook.py +57 -1
- package/src/commands/self-update.js +97 -0
- package/src/lib/manifest.js +53 -1
- package/src/lib/self-check.js +98 -7
- package/src/lib/update-check.js +209 -16
|
@@ -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-
|
|
2
|
+
"syncedAt": "2026-07-07T12:53:36.169Z",
|
|
3
3
|
"syncedFrom": "vendor/skill-garden",
|
|
4
|
-
"sourceCommit": "
|
|
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.
|
|
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(
|
|
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
|
}
|
package/src/lib/manifest.js
CHANGED
|
@@ -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
|
};
|
package/src/lib/self-check.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
334
|
-
if (!
|
|
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
|
-
|
|
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
|
);
|
package/src/lib/update-check.js
CHANGED
|
@@ -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
|
-
*
|
|
25
|
-
* 返回 null —— 调用方据此「拿不到就当没这回事」继续主流程。
|
|
32
|
+
* 从 npm registry 根文档解析 dist-tags。
|
|
26
33
|
*
|
|
27
|
-
*
|
|
28
|
-
* @returns {
|
|
34
|
+
* @param {object} json registry 根文档 JSON
|
|
35
|
+
* @returns {{latest:string|null,beta:string|null}|null} 可用 dist-tags
|
|
29
36
|
*/
|
|
30
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
* 保留这个导出是为了兼容已有调用方;新逻辑应优先使用 `
|
|
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
|
-
|
|
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
|
|
221
|
-
if (!
|
|
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
|
-
|
|
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 一致)
|