flower-trellis 0.4.1 → 0.4.3
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/README.md +91 -2
- package/enhancements/MANIFEST.json +1 -1
- package/package.json +1 -1
- package/src/assets/flower_update_hook.py +135 -0
- package/src/cli.js +27 -0
- package/src/commands/self-check.js +19 -0
- package/src/commands/self-update.js +108 -0
- package/src/commands/update-check.js +86 -0
- package/src/lib/apply-enhancements.js +12 -2
- package/src/lib/claude-tweaks.js +98 -0
- package/src/lib/codex-tweaks.js +41 -18
- package/src/lib/flower-assets.js +29 -0
- package/src/lib/manifest.js +87 -2
- package/src/lib/self-check.js +352 -0
package/README.md
CHANGED
|
@@ -52,6 +52,15 @@ flower-trellis init -u <your-name> -y
|
|
|
52
52
|
# 升级 Trellis 并按新版本重新叠加强化包
|
|
53
53
|
flower-trellis update
|
|
54
54
|
|
|
55
|
+
# 检查当前项目是否需要更新(稳定 JSON,供 AI / hook 使用)
|
|
56
|
+
flower-trellis self-check --json --target .
|
|
57
|
+
|
|
58
|
+
# 执行受控自更新:升级 flower-trellis,再默认 --force 重叠加当前项目
|
|
59
|
+
flower-trellis self-update --target . --yes
|
|
60
|
+
|
|
61
|
+
# 管理启动更新检查策略
|
|
62
|
+
flower-trellis update-check get --target .
|
|
63
|
+
|
|
55
64
|
# 交互管理通用技能,并查看工作流强化包
|
|
56
65
|
flower-trellis skill
|
|
57
66
|
|
|
@@ -70,6 +79,9 @@ flower-trellis -v
|
|
|
70
79
|
|------|------|
|
|
71
80
|
| `init` | 安装 Trellis 并叠加强化包(默认命令,裸跑等同 `init`) |
|
|
72
81
|
| `update` | 升级 Trellis,并按新版本重新叠加强化包 |
|
|
82
|
+
| `self-check` | 输出启动更新检查 JSON,供 Codex / Claude Code hook 和 AI 自动化读取 |
|
|
83
|
+
| `self-update` | 受控升级 flower-trellis 并对目标项目执行完整 `flower-trellis update` 重叠加 |
|
|
84
|
+
| `update-check` | 管理 `.trellis/.flower-manifest.json` 内的启动更新检查策略 |
|
|
73
85
|
| `skill` | 打开交互菜单:启用或停用通用技能,只读查看工作流强化包 |
|
|
74
86
|
| `uninstall` | 移除 Trellis 本体并清理强化包残留(支持 `-y` / `--dry-run`) |
|
|
75
87
|
| `<其它命令>` | 原样透传给 Trellis,覆盖其现有及未来子命令 |
|
|
@@ -99,16 +111,93 @@ flower-trellis -v
|
|
|
99
111
|
- **非交互**(`-y` 或非 TTY):仅打印一行升级提示,不弹确认、不阻塞。
|
|
100
112
|
- **跳过检测**:经 `npx` 运行(本就是最新版)、或显式 `--no-update-check` / `FLOWER_NO_UPDATE_CHECK=1` 时不检测。
|
|
101
113
|
|
|
114
|
+
### 启动更新检查
|
|
115
|
+
|
|
116
|
+
运行 `flower-trellis init` / `flower-trellis update` 后,目标项目会安装轻量启动 hook:
|
|
117
|
+
|
|
118
|
+
- Codex:向 `.codex/hooks.json` 的 `SessionStart` 追加 `.trellis/scripts/flower_update_hook.py`。
|
|
119
|
+
- Claude Code:只向 `.claude/settings.json` 的 `SessionStart` `startup` matcher 追加该 hook,不挂 `clear` / `compact`。
|
|
120
|
+
|
|
121
|
+
启动 hook 不会直接安装 npm 包,也不会直接改项目文件。它只调用:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
flower-trellis self-check --json --target .
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
发现可更新或项目已铺版本不一致时,向 AI 注入 `<flower-update>` 上下文。无更新、离线、关闭检查、npx 临时运行时默认不打扰。
|
|
128
|
+
|
|
129
|
+
检查分两层:
|
|
130
|
+
|
|
131
|
+
- **本地一致性检查**:每次启动都会比对当前安装的 `flower-trellis` / 捆绑 Trellis 版本与项目 `.trellis/.flower-manifest.json` / `.trellis/.version`;不受 `intervalHours` 限制。
|
|
132
|
+
- **远程版本探测**:访问 npm registry 读取 `flower-trellis` dist-tags;受 `intervalHours` 节流,带超时,失败静默。
|
|
133
|
+
|
|
134
|
+
策略和缓存统一保存在 `.trellis/.flower-manifest.json` 的 `updateCheck`:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"updateCheck": {
|
|
139
|
+
"enabled": true,
|
|
140
|
+
"policy": "ask",
|
|
141
|
+
"intervalHours": 8,
|
|
142
|
+
"lastCheckedAt": "2026-07-07T00:00:00.000Z",
|
|
143
|
+
"lastRemote": { "latest": "0.4.2", "beta": null },
|
|
144
|
+
"lastStatus": "update_available",
|
|
145
|
+
"lastErrorCode": null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`policy` 可选:
|
|
151
|
+
|
|
152
|
+
| policy | 行为 |
|
|
153
|
+
|--------|------|
|
|
154
|
+
| `off` | 不做启动更新检查,也不联网 |
|
|
155
|
+
| `notify` | 只注入提示和手动命令,AI 不主动询问或执行 |
|
|
156
|
+
| `ask` | 默认值;AI 发现可更新后先询问用户 |
|
|
157
|
+
| `auto` | 安全条件满足时 AI 可执行 `self-update`;dirty 工作区或活跃任务等情况会降级为 `ask` |
|
|
158
|
+
|
|
159
|
+
管理策略:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
flower-trellis update-check get --target .
|
|
163
|
+
flower-trellis update-check set --policy auto --interval-hours 12 --target .
|
|
164
|
+
flower-trellis update-check disable --target . # 只设置 enabled=false,不改 policy
|
|
165
|
+
flower-trellis update-check enable --target . # 只设置 enabled=true,沿用原 policy
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
执行更新:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# 预览,不写入
|
|
172
|
+
flower-trellis self-update --target . --dry-run
|
|
173
|
+
|
|
174
|
+
# 执行:先升级全局 flower-trellis,再对项目执行完整 update
|
|
175
|
+
flower-trellis self-update --target . --yes
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
项目重叠加阶段默认等价于 Trellis 交互里的 **Apply Overwrite to all**:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
flower-trellis update --target . --no-update-check --force
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
如需改用其它上游冲突策略,在 `--` 后透传:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
flower-trellis self-update --target . --yes -- --skip-all
|
|
188
|
+
flower-trellis self-update --target . --yes -- --create-new
|
|
189
|
+
```
|
|
190
|
+
|
|
102
191
|
## 工作原理
|
|
103
192
|
|
|
104
193
|
`init` 的执行流程:
|
|
105
194
|
|
|
106
195
|
```
|
|
107
|
-
flower banner → 平台多选菜单 → Trellis 原生交互(模板 / monorepo / 冲突)→ 叠加强化包 →
|
|
196
|
+
flower banner → 平台多选菜单 → Trellis 原生交互(模板 / monorepo / 冲突)→ 叠加强化包 → 平台后处理
|
|
108
197
|
```
|
|
109
198
|
|
|
110
199
|
- **统一品牌头部**:Trellis 子进程在伪终端(`node-pty`)中运行,其原生的模板 / monorepo / 冲突等交互完整保留,但重复打印的启动 banner 被过滤,全程只呈现一个 flower banner。
|
|
111
|
-
- **按平台铺设技能**:Claude 铺到 `.claude/skills`,Codex / Gemini 等铺到 `.agents/skills
|
|
200
|
+
- **按平台铺设技能**:Claude 铺到 `.claude/skills`,Codex / Gemini 等铺到 `.agents/skills`;并做平台后处理:Codex 兼容清理旧 `config.toml` 的 `[features.multi_agent_v2]`,在保留上游 hooks 的基础上补全 `SessionStart`;Claude Code 只在 `startup` SessionStart 挂载启动更新检查。
|
|
112
201
|
- **幂等执行**:`workflow.md` 注入前先按 `BEGIN/END` 标记清除旧块再重注入(块数恒定,不会翻倍,首次注入前备份到 `.trellis/.backup-flower/`);技能文件覆盖式铺设,并通过 `.trellis/.flower-manifest.json` 记录已铺路径,升级时删除已淘汰项。
|
|
113
202
|
- **上线事项账本**:强化包通过 finish-work skill override 在归档前智能识别 SQL、配置、批处理 / 部署脚本 / 数据修复、外部系统 / 依赖平台等上线事项,必要时写入任务 `release.md`;`trellis-release` 可在正式上线前核对任务文档、`release.md` 和 git 证据,生成 `YYYY-MM-DD-<release-slug>.md` 格式的版本 / 批次操作单。
|
|
114
203
|
- **安全中止**:`Ctrl+C` 取消后不会继续叠加。
|
package/package.json
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""SessionStart 启动更新检查 hook。
|
|
4
|
+
|
|
5
|
+
脚本只做只读检查和上下文注入:调用 `flower-trellis self-check --json`,
|
|
6
|
+
发现可执行更新时输出 `<flower-update>` 块。失败、离线、无更新或关闭检查时静默退出,
|
|
7
|
+
避免影响 Codex / Claude Code 正常启动。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ACTIONABLE_STATUSES = {"update_available", "project_out_of_sync"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _debug(message: str) -> None:
|
|
23
|
+
"""在显式调试时输出简短错误。"""
|
|
24
|
+
if os.environ.get("FLOWER_UPDATE_HOOK_DEBUG"):
|
|
25
|
+
print(f"flower_update_hook: {message}", file=sys.stderr)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _project_dir(hook_input: dict) -> Path:
|
|
29
|
+
"""从平台环境变量或 hook stdin 解析项目目录。"""
|
|
30
|
+
for name in (
|
|
31
|
+
"CLAUDE_PROJECT_DIR",
|
|
32
|
+
"CODEX_PROJECT_DIR",
|
|
33
|
+
"CURSOR_PROJECT_DIR",
|
|
34
|
+
"GEMINI_PROJECT_DIR",
|
|
35
|
+
"QODER_PROJECT_DIR",
|
|
36
|
+
"CODEBUDDY_PROJECT_DIR",
|
|
37
|
+
"TRAE_PROJECT_DIR",
|
|
38
|
+
):
|
|
39
|
+
value = os.environ.get(name)
|
|
40
|
+
if value:
|
|
41
|
+
return Path(value).resolve()
|
|
42
|
+
return Path(str(hook_input.get("cwd") or ".")).resolve()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_self_check(project_dir: Path) -> dict | None:
|
|
46
|
+
"""执行 flower-trellis self-check 并解析 JSON。"""
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
[
|
|
50
|
+
"flower-trellis",
|
|
51
|
+
"self-check",
|
|
52
|
+
"--json",
|
|
53
|
+
"--target",
|
|
54
|
+
str(project_dir),
|
|
55
|
+
],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
errors="replace",
|
|
60
|
+
timeout=8,
|
|
61
|
+
cwd=str(project_dir),
|
|
62
|
+
)
|
|
63
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as exc:
|
|
64
|
+
_debug(str(exc))
|
|
65
|
+
return None
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
_debug(result.stderr.strip() or f"退出码 {result.returncode}")
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(result.stdout)
|
|
71
|
+
except json.JSONDecodeError as exc:
|
|
72
|
+
_debug(f"JSON 解析失败:{exc}")
|
|
73
|
+
return None
|
|
74
|
+
return data if isinstance(data, dict) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _format_context(data: dict) -> str:
|
|
78
|
+
"""把 self-check JSON 转成给 AI 读取的短上下文块。"""
|
|
79
|
+
current = data.get("current") or {}
|
|
80
|
+
project = data.get("project") or {}
|
|
81
|
+
remote = data.get("remote") or {}
|
|
82
|
+
ai = data.get("ai") or {}
|
|
83
|
+
safety = data.get("safety") or {}
|
|
84
|
+
lines = [
|
|
85
|
+
"<flower-update>",
|
|
86
|
+
f"status: {data.get('status')}",
|
|
87
|
+
f"policy: {data.get('policy')}",
|
|
88
|
+
f"current_flower: {current.get('flowerVersion')}",
|
|
89
|
+
f"project_flower: {project.get('flowerVersion')}",
|
|
90
|
+
f"bundled_trellis: {current.get('bundledTrellisVersion')}",
|
|
91
|
+
f"project_trellis: {project.get('trellisVersion')}",
|
|
92
|
+
]
|
|
93
|
+
if remote.get("tags"):
|
|
94
|
+
lines.append(f"remote: {json.dumps(remote.get('tags'), ensure_ascii=False)}")
|
|
95
|
+
command = (data.get("commands") or {}).get("recommended") or ai.get("command")
|
|
96
|
+
if command:
|
|
97
|
+
lines.append(f"recommended_command: {command}")
|
|
98
|
+
if safety.get("reasons"):
|
|
99
|
+
lines.append(f"safety_reasons: {', '.join(safety.get('reasons') or [])}")
|
|
100
|
+
if ai.get("instruction"):
|
|
101
|
+
lines.append(f"ai_instruction: {ai.get('instruction')}")
|
|
102
|
+
lines.append("</flower-update>")
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _emit_context(context: str) -> None:
|
|
107
|
+
"""同时兼容 Codex / Claude Code / Cursor 风格的 hook JSON 输出。"""
|
|
108
|
+
result = {
|
|
109
|
+
"suppressOutput": True,
|
|
110
|
+
"systemMessage": "flower-trellis update context injected",
|
|
111
|
+
"hookSpecificOutput": {
|
|
112
|
+
"hookEventName": "SessionStart",
|
|
113
|
+
"additionalContext": context,
|
|
114
|
+
},
|
|
115
|
+
"additional_context": context,
|
|
116
|
+
}
|
|
117
|
+
print(json.dumps(result, ensure_ascii=False), flush=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> None:
|
|
121
|
+
try:
|
|
122
|
+
hook_input = json.loads(sys.stdin.read() or "{}")
|
|
123
|
+
if not isinstance(hook_input, dict):
|
|
124
|
+
hook_input = {}
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
hook_input = {}
|
|
127
|
+
|
|
128
|
+
data = _run_self_check(_project_dir(hook_input))
|
|
129
|
+
if not data or data.get("status") not in ACTIONABLE_STATUSES:
|
|
130
|
+
return
|
|
131
|
+
_emit_context(_format_context(data))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
main()
|
package/src/cli.js
CHANGED
|
@@ -66,6 +66,9 @@ function printHelp() {
|
|
|
66
66
|
用法:
|
|
67
67
|
flower-trellis [init] [trellis flags] [flower flags] 安装 + 叠加强化包(默认命令)
|
|
68
68
|
flower-trellis update [trellis flags] [flower flags] 升级 + 按新版本重新叠加
|
|
69
|
+
flower-trellis self-check --json [--target <dir>] 输出启动更新检查 JSON
|
|
70
|
+
flower-trellis self-update --target <dir> --yes 自更新 + 项目重叠加
|
|
71
|
+
flower-trellis update-check <get|set|disable|enable> 管理启动更新策略
|
|
69
72
|
flower-trellis skill [flower flags] 交互管理通用技能
|
|
70
73
|
flower-trellis uninstall [-y | --dry-run] 卸载 + 清理强化残留
|
|
71
74
|
flower-trellis <其它命令> [...] 透传给 trellis(面向未来)
|
|
@@ -79,6 +82,12 @@ flower 自有 flag:
|
|
|
79
82
|
--target <dir> 目标目录(默认当前目录)
|
|
80
83
|
--no-update-check 本次跳过 flower-trellis 新版本检测(等价 FLOWER_NO_UPDATE_CHECK=1)
|
|
81
84
|
|
|
85
|
+
启动更新检查:
|
|
86
|
+
self-check --json 稳定输出检查 JSON
|
|
87
|
+
self-update --yes [--dry-run] 执行或预览全局更新与项目 update
|
|
88
|
+
update-check set --policy <off|notify|ask|auto> [--interval-hours <n>]
|
|
89
|
+
update-check disable|enable 只切换 enabled,不覆盖已选 policy
|
|
90
|
+
|
|
82
91
|
命令别名:flower-trellis 可简写为 ftl 或 ft(三者完全等价)。
|
|
83
92
|
init / update 启动时会顺带检测 flower-trellis 自身是否有新版(联网、带超时,失败静默)。
|
|
84
93
|
skill 可启用或停用通用技能,并只读展示工作流强化包。
|
|
@@ -99,9 +108,17 @@ function parse(argv) {
|
|
|
99
108
|
let updateCheck = true;
|
|
100
109
|
const skills = [];
|
|
101
110
|
const passthrough = [];
|
|
111
|
+
const forwarded = [];
|
|
102
112
|
|
|
103
113
|
for (let i = 0; i < argv.length; i++) {
|
|
104
114
|
const a = argv[i];
|
|
115
|
+
if (a === "--") {
|
|
116
|
+
forwarded.push(...argv.slice(i + 1));
|
|
117
|
+
if (command !== "self-update") {
|
|
118
|
+
passthrough.push(...argv.slice(i + 1));
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
105
122
|
// 第一个非 flag token 视为子命令
|
|
106
123
|
if (command === null && !a.startsWith("-")) {
|
|
107
124
|
command = a;
|
|
@@ -143,6 +160,7 @@ function parse(argv) {
|
|
|
143
160
|
skills,
|
|
144
161
|
variant,
|
|
145
162
|
updateCheck,
|
|
163
|
+
forwarded,
|
|
146
164
|
},
|
|
147
165
|
};
|
|
148
166
|
}
|
|
@@ -179,6 +197,15 @@ async function main() {
|
|
|
179
197
|
} else if (cmd === "update") {
|
|
180
198
|
const { update } = await import("./commands/update.js");
|
|
181
199
|
await update(ctx);
|
|
200
|
+
} else if (cmd === "self-check") {
|
|
201
|
+
const { selfCheck } = await import("./commands/self-check.js");
|
|
202
|
+
await selfCheck(ctx);
|
|
203
|
+
} else if (cmd === "self-update") {
|
|
204
|
+
const { selfUpdate } = await import("./commands/self-update.js");
|
|
205
|
+
await selfUpdate(ctx);
|
|
206
|
+
} else if (cmd === "update-check") {
|
|
207
|
+
const { updateCheck } = await import("./commands/update-check.js");
|
|
208
|
+
await updateCheck(ctx);
|
|
182
209
|
} else if (cmd === "skill") {
|
|
183
210
|
const { skill } = await import("./commands/skill.js");
|
|
184
211
|
await skill(ctx);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildSelfCheck } from "../lib/self-check.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* flower-trellis self-check:输出结构化启动更新检查结果。
|
|
5
|
+
*
|
|
6
|
+
* 供 Codex / Claude Code SessionStart hook 和 AI 自动化读取。无论是否发现更新,
|
|
7
|
+
* 都稳定输出 JSON,避免 hook 侧靠空 stdout 猜状态。
|
|
8
|
+
*
|
|
9
|
+
* @param {object} ctx 见 cli.js 的 parse()
|
|
10
|
+
* @returns {Promise<void>}
|
|
11
|
+
*/
|
|
12
|
+
export async function selfCheck(ctx) {
|
|
13
|
+
const forceRemote = ctx.passthrough.includes("--force-remote");
|
|
14
|
+
if (ctx.updateCheck === false) {
|
|
15
|
+
process.env.FLOWER_NO_UPDATE_CHECK = "1";
|
|
16
|
+
}
|
|
17
|
+
const result = await buildSelfCheck(ctx.target, { forceRemote });
|
|
18
|
+
console.log(JSON.stringify(result, null, 2));
|
|
19
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
buildSelfCheck,
|
|
4
|
+
projectUpdateCommand,
|
|
5
|
+
projectUpdateForwardArgs,
|
|
6
|
+
safetyState,
|
|
7
|
+
} from "../lib/self-check.js";
|
|
8
|
+
|
|
9
|
+
/** 判断参数里是否包含指定 flag。 */
|
|
10
|
+
function hasFlag(args, name) {
|
|
11
|
+
return args.includes(name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 执行命令并在失败时给出统一错误。 */
|
|
15
|
+
function runCommand(command, args, cwd, failureMessage) {
|
|
16
|
+
const res = spawnSync(command, args, {
|
|
17
|
+
cwd,
|
|
18
|
+
stdio: "inherit",
|
|
19
|
+
shell: process.platform === "win32",
|
|
20
|
+
});
|
|
21
|
+
if (res.status !== 0) {
|
|
22
|
+
const reason = res.error ? res.error.message : `退出码 ${res.status ?? 1}`;
|
|
23
|
+
throw new Error(`${failureMessage}(${reason})`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* flower-trellis self-update:执行受控自更新与项目重叠加。
|
|
29
|
+
*
|
|
30
|
+
* 默认项目 update 阶段追加 `--force`,等价 Trellis 交互里的 Apply Overwrite to all。
|
|
31
|
+
* 用户可通过 `--` 透传 `--skip-all` / `--create-new` 等上游冲突策略覆盖默认。
|
|
32
|
+
*
|
|
33
|
+
* @param {object} ctx 见 cli.js 的 parse()
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
export async function selfUpdate(ctx) {
|
|
37
|
+
const dryRun = hasFlag(ctx.passthrough, "--dry-run");
|
|
38
|
+
const yes = hasFlag(ctx.passthrough, "--yes") || hasFlag(ctx.passthrough, "-y");
|
|
39
|
+
const projectOnly = hasFlag(ctx.passthrough, "--project-only");
|
|
40
|
+
const forwarded = ctx.forwarded || [];
|
|
41
|
+
const projectArgs = [
|
|
42
|
+
"update",
|
|
43
|
+
"--target",
|
|
44
|
+
ctx.target,
|
|
45
|
+
"--no-update-check",
|
|
46
|
+
...projectUpdateForwardArgs(forwarded),
|
|
47
|
+
];
|
|
48
|
+
const check = await buildSelfCheck(ctx.target, {
|
|
49
|
+
writeCache: !dryRun,
|
|
50
|
+
forceRemote: !projectOnly,
|
|
51
|
+
});
|
|
52
|
+
const shouldInstallFlower = !projectOnly && check.status === "update_available" && check.recommendation;
|
|
53
|
+
const effectiveSafety = check.safety || safetyState(
|
|
54
|
+
ctx.target,
|
|
55
|
+
projectOnly ? "project_out_of_sync" : check.status,
|
|
56
|
+
projectUpdateCommand(ctx.target, forwarded),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (dryRun) {
|
|
60
|
+
console.log("self-update dry-run:");
|
|
61
|
+
if (shouldInstallFlower) {
|
|
62
|
+
console.log(` · 全局升级:${check.recommendation.command}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.log(" · 全局升级:跳过");
|
|
65
|
+
}
|
|
66
|
+
console.log(` · 项目更新:${projectUpdateCommand(ctx.target, forwarded)}`);
|
|
67
|
+
console.log(` · 当前状态:${check.status}`);
|
|
68
|
+
console.log(` · 目标:${ctx.target}`);
|
|
69
|
+
console.log(` · 当前 flower:${check.current?.flowerVersion || "unknown"}`);
|
|
70
|
+
console.log(` · 项目 flower:${check.project?.flowerVersion || "unknown"}`);
|
|
71
|
+
console.log(` · 捆绑 Trellis:${check.current?.bundledTrellisVersion || "unknown"}`);
|
|
72
|
+
console.log(` · 项目 Trellis:${check.project?.trellisVersion || "unknown"}`);
|
|
73
|
+
console.log(
|
|
74
|
+
` · 安全检查:${effectiveSafety.safe ? "通过" : `需确认(${effectiveSafety.reasons.join(", ") || "无可用结果"})`}`,
|
|
75
|
+
);
|
|
76
|
+
console.log(` · 写入:否`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!yes) {
|
|
81
|
+
throw new Error("self-update 执行写入前需要 --yes;如需预览请使用 --dry-run");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!shouldInstallFlower && !projectOnly && check.status !== "project_out_of_sync") {
|
|
85
|
+
console.log(` · 当前状态:${check.status},无需执行 self-update`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (shouldInstallFlower) {
|
|
90
|
+
runCommand(
|
|
91
|
+
"npm",
|
|
92
|
+
["i", "-g", `flower-trellis@${check.recommendation.tag}`],
|
|
93
|
+
ctx.target,
|
|
94
|
+
"全局 flower-trellis 升级失败",
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
console.log(" · 跳过全局 flower-trellis 升级");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
runCommand(
|
|
101
|
+
"flower-trellis",
|
|
102
|
+
projectArgs,
|
|
103
|
+
ctx.target,
|
|
104
|
+
"目标项目重叠加失败,请手动运行:" + projectUpdateCommand(ctx.target, forwarded),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
console.log(`\n🌸 flower-trellis self-update 完成 → ${ctx.target}`);
|
|
108
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
manifestPath,
|
|
5
|
+
readManifest,
|
|
6
|
+
readUpdateCheck,
|
|
7
|
+
writeUpdateCheck,
|
|
8
|
+
} from "../lib/manifest.js";
|
|
9
|
+
|
|
10
|
+
const POLICIES = new Set(["off", "notify", "ask", "auto"]);
|
|
11
|
+
|
|
12
|
+
/** 读取 flag 后面的取值。 */
|
|
13
|
+
function optionValue(args, name) {
|
|
14
|
+
const index = args.indexOf(name);
|
|
15
|
+
if (index === -1) return null;
|
|
16
|
+
return args[index + 1] || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 确保目标是 Trellis 项目。 */
|
|
20
|
+
function assertTrellisProject(target) {
|
|
21
|
+
if (!fs.existsSync(path.join(target, ".trellis"))) {
|
|
22
|
+
throw new Error(`目标不是 Trellis 项目:${target}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* flower-trellis update-check:管理 manifest 内的启动更新检查策略。
|
|
28
|
+
*
|
|
29
|
+
* @param {object} ctx 见 cli.js 的 parse()
|
|
30
|
+
*/
|
|
31
|
+
export async function updateCheck(ctx) {
|
|
32
|
+
const args = ctx.passthrough;
|
|
33
|
+
const action = args.find((arg) => !arg.startsWith("-")) || "get";
|
|
34
|
+
assertTrellisProject(ctx.target);
|
|
35
|
+
|
|
36
|
+
if (action === "get") {
|
|
37
|
+
const manifest = readManifest(ctx.target);
|
|
38
|
+
console.log(`manifest: ${manifestPath(ctx.target)}`);
|
|
39
|
+
console.log(JSON.stringify(readUpdateCheck(ctx.target), null, 2));
|
|
40
|
+
if (!manifest) console.log(" · manifest 不存在,当前显示默认策略");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (action === "set") {
|
|
45
|
+
const policy = optionValue(args, "--policy");
|
|
46
|
+
const intervalText = optionValue(args, "--interval-hours");
|
|
47
|
+
const patch = {};
|
|
48
|
+
if (policy) {
|
|
49
|
+
if (!POLICIES.has(policy)) {
|
|
50
|
+
throw new Error("policy 只能是 off / notify / ask / auto");
|
|
51
|
+
}
|
|
52
|
+
patch.policy = policy;
|
|
53
|
+
patch.enabled = policy !== "off";
|
|
54
|
+
}
|
|
55
|
+
if (intervalText !== null) {
|
|
56
|
+
const intervalHours = Number(intervalText);
|
|
57
|
+
if (!Number.isFinite(intervalHours) || intervalHours < 0) {
|
|
58
|
+
throw new Error("--interval-hours 必须是非负数字");
|
|
59
|
+
}
|
|
60
|
+
patch.intervalHours = intervalHours;
|
|
61
|
+
}
|
|
62
|
+
if (!Object.keys(patch).length) {
|
|
63
|
+
throw new Error("update-check set 需要 --policy 或 --interval-hours");
|
|
64
|
+
}
|
|
65
|
+
writeUpdateCheck(ctx.target, patch);
|
|
66
|
+
console.log(" ✓ updateCheck 策略已更新");
|
|
67
|
+
console.log(JSON.stringify(readUpdateCheck(ctx.target), null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (action === "disable") {
|
|
72
|
+
writeUpdateCheck(ctx.target, { enabled: false });
|
|
73
|
+
console.log(" ✓ 启动更新检查已关闭(policy 保留)");
|
|
74
|
+
console.log(JSON.stringify(readUpdateCheck(ctx.target), null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (action === "enable") {
|
|
79
|
+
writeUpdateCheck(ctx.target, { enabled: true });
|
|
80
|
+
console.log(" ✓ 启动更新检查已启用(policy 沿用)");
|
|
81
|
+
console.log(JSON.stringify(readUpdateCheck(ctx.target), null, 2));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error("update-check 只支持 get / set / disable / enable");
|
|
86
|
+
}
|
|
@@ -5,6 +5,8 @@ import { copyScriptAssets } from "./copy-scripts.js";
|
|
|
5
5
|
import { injectWorkflow } from "./workflow-inject.js";
|
|
6
6
|
import { injectSkillOverrides } from "./skill-override-inject.js";
|
|
7
7
|
import { applyCodexTweaks } from "./codex-tweaks.js";
|
|
8
|
+
import { applyClaudeTweaks } from "./claude-tweaks.js";
|
|
9
|
+
import { copyFlowerAssets } from "./flower-assets.js";
|
|
8
10
|
import { readManifest, writeManifest } from "./manifest.js";
|
|
9
11
|
import { flowerVersion } from "./versions.js";
|
|
10
12
|
import { rmrf } from "./fs-utils.js";
|
|
@@ -63,8 +65,11 @@ export function applyEnhancements(target, opts = {}) {
|
|
|
63
65
|
variantDir,
|
|
64
66
|
skills,
|
|
65
67
|
);
|
|
66
|
-
const installed =
|
|
67
|
-
|
|
68
|
+
const { installed: flowerInstalled, paths: flowerPaths } = skills.length === 0
|
|
69
|
+
? copyFlowerAssets(target)
|
|
70
|
+
: { installed: [], paths: [] };
|
|
71
|
+
const installed = [...skillInstalled, ...scriptInstalled, ...flowerInstalled];
|
|
72
|
+
const newPaths = [...skillPaths, ...scriptPaths, ...flowerPaths];
|
|
68
73
|
const where = [];
|
|
69
74
|
if (newPaths.some((p) => p.startsWith(".claude/skills"))) where.push(".claude/skills");
|
|
70
75
|
if (newPaths.some((p) => p.startsWith(".agents/skills"))) where.push(".agents/skills");
|
|
@@ -152,5 +157,10 @@ export function applyEnhancements(target, opts = {}) {
|
|
|
152
157
|
console.log(` ✓ codex 调整:${seg};hooks.json 已合并 SessionStart;${dispatch}`);
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
const claude = applyClaudeTweaks(target);
|
|
161
|
+
if (claude.applied) {
|
|
162
|
+
console.log(" ✓ claude 调整:settings.json 已合并 startup 更新检查 hook");
|
|
163
|
+
}
|
|
164
|
+
|
|
155
165
|
return { variant, installed };
|
|
156
166
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FLOWER_UPDATE_HOOK_REL } from "./flower-assets.js";
|
|
4
|
+
|
|
5
|
+
const WORKFLOW_HOOK_SCRIPT = ".claude/hooks/inject-workflow-state.py";
|
|
6
|
+
const DEFAULT_COMMAND = `python3 ${FLOWER_UPDATE_HOOK_REL}`;
|
|
7
|
+
|
|
8
|
+
/** 容错读取 Claude settings JSON。 */
|
|
9
|
+
function readSettings(settingsPath) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
12
|
+
if (parsed && typeof parsed === "object") {
|
|
13
|
+
const hooks = parsed.hooks && typeof parsed.hooks === "object" ? parsed.hooks : {};
|
|
14
|
+
return { ...parsed, hooks };
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// 缺失或损坏时用空配置重建,避免后处理阻断 init/update。
|
|
18
|
+
}
|
|
19
|
+
return { hooks: {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 从 Claude UserPromptSubmit hook 推导 Python 命令前缀。 */
|
|
23
|
+
function flowerUpdateCommand(config) {
|
|
24
|
+
const groups = Array.isArray(config.hooks.UserPromptSubmit)
|
|
25
|
+
? config.hooks.UserPromptSubmit
|
|
26
|
+
: [];
|
|
27
|
+
for (const group of groups) {
|
|
28
|
+
const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
29
|
+
for (const hook of hooks) {
|
|
30
|
+
if (hook?.type !== "command" || typeof hook.command !== "string") continue;
|
|
31
|
+
if (hook.command.includes(WORKFLOW_HOOK_SCRIPT)) {
|
|
32
|
+
return hook.command.replace(WORKFLOW_HOOK_SCRIPT, FLOWER_UPDATE_HOOK_REL);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return DEFAULT_COMMAND;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 判断 hook 列表里是否已有 flower update hook。 */
|
|
40
|
+
function hasFlowerHook(hooks) {
|
|
41
|
+
return hooks.some(
|
|
42
|
+
(hook) => hook?.type === "command" &&
|
|
43
|
+
typeof hook.command === "string" &&
|
|
44
|
+
hook.command.includes(FLOWER_UPDATE_HOOK_REL),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 合并 Claude startup hook,并确保 clear / compact 不运行 update hook。 */
|
|
49
|
+
function mergeClaudeHooks(settingsPath) {
|
|
50
|
+
const config = readSettings(settingsPath);
|
|
51
|
+
const groups = Array.isArray(config.hooks.SessionStart)
|
|
52
|
+
? config.hooks.SessionStart
|
|
53
|
+
: [];
|
|
54
|
+
const command = flowerUpdateCommand(config);
|
|
55
|
+
let startup = groups.find((group) => group?.matcher === "startup");
|
|
56
|
+
if (!startup) {
|
|
57
|
+
startup = { matcher: "startup", hooks: [] };
|
|
58
|
+
groups.unshift(startup);
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(startup.hooks)) startup.hooks = [];
|
|
61
|
+
|
|
62
|
+
let changed = false;
|
|
63
|
+
for (const group of groups) {
|
|
64
|
+
if (group === startup || !Array.isArray(group?.hooks)) continue;
|
|
65
|
+
const before = group.hooks.length;
|
|
66
|
+
group.hooks = group.hooks.filter(
|
|
67
|
+
(hook) => !(hook?.type === "command" &&
|
|
68
|
+
typeof hook.command === "string" &&
|
|
69
|
+
hook.command.includes(FLOWER_UPDATE_HOOK_REL)),
|
|
70
|
+
);
|
|
71
|
+
if (group.hooks.length !== before) changed = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!hasFlowerHook(startup.hooks)) {
|
|
75
|
+
startup.hooks.push({ type: "command", command, timeout: 8 });
|
|
76
|
+
changed = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
config.hooks.SessionStart = groups;
|
|
80
|
+
const desired = JSON.stringify(config, null, 2) + "\n";
|
|
81
|
+
const current = fs.existsSync(settingsPath) ? fs.readFileSync(settingsPath, "utf8") : "";
|
|
82
|
+
if (current === desired) return changed;
|
|
83
|
+
fs.writeFileSync(settingsPath, desired);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Claude Code 平台后处理入口。仅当 `.claude/` 存在时执行。
|
|
89
|
+
*
|
|
90
|
+
* @param {string} target 目标项目根
|
|
91
|
+
* @returns {{applied:boolean,settingsWritten?:boolean}}
|
|
92
|
+
*/
|
|
93
|
+
export function applyClaudeTweaks(target) {
|
|
94
|
+
const claudeDir = path.join(target, ".claude");
|
|
95
|
+
if (!fs.existsSync(claudeDir)) return { applied: false };
|
|
96
|
+
const settingsWritten = mergeClaudeHooks(path.join(claudeDir, "settings.json"));
|
|
97
|
+
return { applied: true, settingsWritten };
|
|
98
|
+
}
|
package/src/lib/codex-tweaks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { FLOWER_UPDATE_HOOK_REL } from "./flower-assets.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* flower-trellis 对 Trellis 生成的 codex 配置的定制后处理。
|
|
@@ -239,23 +240,40 @@ function sessionStartCommand(config) {
|
|
|
239
240
|
return `python3 -X utf8 ${SESSION_START_SCRIPT}`;
|
|
240
241
|
}
|
|
241
242
|
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
243
|
+
/** 从 Trellis SessionStart 命令推导 flower update hook 命令。 */
|
|
244
|
+
function flowerUpdateCommand(config) {
|
|
245
|
+
const command = sessionStartCommand(config);
|
|
246
|
+
if (command.includes(SESSION_START_SCRIPT)) {
|
|
247
|
+
return command.replace(SESSION_START_SCRIPT, FLOWER_UPDATE_HOOK_REL);
|
|
248
|
+
}
|
|
249
|
+
return `python3 -X utf8 ${FLOWER_UPDATE_HOOK_REL}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 判断 hooks 数组里是否已有指定命令。 */
|
|
253
|
+
function hasCommand(hooks, commandNeedle) {
|
|
254
|
+
return hooks.some(
|
|
255
|
+
(hook) => hook?.type === "command" &&
|
|
256
|
+
typeof hook.command === "string" &&
|
|
257
|
+
hook.command.includes(commandNeedle),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 向 SessionStart 合并单个 command hook,保留既有自定义 hook。 */
|
|
262
|
+
function ensureSessionStartCommand(groups, command, timeout, needle) {
|
|
263
|
+
for (const group of groups) {
|
|
264
|
+
const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
265
|
+
if (hasCommand(hooks, needle)) return false;
|
|
266
|
+
}
|
|
267
|
+
groups.push({
|
|
268
|
+
hooks: [
|
|
269
|
+
{
|
|
270
|
+
type: "command",
|
|
271
|
+
command,
|
|
272
|
+
timeout,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
return true;
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
/**
|
|
@@ -269,7 +287,12 @@ function sessionStartHook(config) {
|
|
|
269
287
|
*/
|
|
270
288
|
function mergeHooks(hooksPath) {
|
|
271
289
|
const config = readHooksConfig(hooksPath);
|
|
272
|
-
config.hooks.SessionStart
|
|
290
|
+
const groups = Array.isArray(config.hooks.SessionStart)
|
|
291
|
+
? config.hooks.SessionStart
|
|
292
|
+
: [];
|
|
293
|
+
ensureSessionStartCommand(groups, sessionStartCommand(config), 30, SESSION_START_SCRIPT);
|
|
294
|
+
ensureSessionStartCommand(groups, flowerUpdateCommand(config), 8, FLOWER_UPDATE_HOOK_REL);
|
|
295
|
+
config.hooks.SessionStart = groups;
|
|
273
296
|
|
|
274
297
|
const desired = JSON.stringify(config, null, 2) + "\n";
|
|
275
298
|
const current = fs.existsSync(hooksPath) ? fs.readFileSync(hooksPath, "utf8") : "";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { copyPath } from "./fs-utils.js";
|
|
3
|
+
import { PKG_ROOT } from "./paths.js";
|
|
4
|
+
|
|
5
|
+
/** flower 自有启动更新 hook 脚本名。 */
|
|
6
|
+
export const FLOWER_UPDATE_HOOK = "flower_update_hook.py";
|
|
7
|
+
|
|
8
|
+
/** flower 自有启动更新 hook 在目标项目内的相对路径。 */
|
|
9
|
+
export const FLOWER_UPDATE_HOOK_REL = `.trellis/scripts/${FLOWER_UPDATE_HOOK}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 复制 flower 自有脚本资产。
|
|
13
|
+
*
|
|
14
|
+
* 这些脚本属于 flower-trellis 自身能力,不从 skill-garden 快照同步,避免和
|
|
15
|
+
* `enhancements/<variant>/scripts` 的边界混淆。
|
|
16
|
+
*
|
|
17
|
+
* @param {string} target 目标项目根
|
|
18
|
+
* @returns {{installed:string[],paths:string[]}} 已安装资产和 manifest 路径
|
|
19
|
+
*/
|
|
20
|
+
export function copyFlowerAssets(target) {
|
|
21
|
+
copyPath(
|
|
22
|
+
path.join(PKG_ROOT, "src", "assets", FLOWER_UPDATE_HOOK),
|
|
23
|
+
path.join(target, ...FLOWER_UPDATE_HOOK_REL.split("/")),
|
|
24
|
+
);
|
|
25
|
+
return {
|
|
26
|
+
installed: [`script:${FLOWER_UPDATE_HOOK}`],
|
|
27
|
+
paths: [FLOWER_UPDATE_HOOK_REL],
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/lib/manifest.js
CHANGED
|
@@ -11,6 +11,16 @@ import path from "node:path";
|
|
|
11
11
|
* 放在 .trellis/ 下,随项目的 Trellis 生命周期存在(uninstall 删 .trellis 时一并消失)。
|
|
12
12
|
*/
|
|
13
13
|
const MANIFEST_REL = path.join(".trellis", ".flower-manifest.json");
|
|
14
|
+
const UPDATE_POLICIES = new Set(["off", "notify", "ask", "auto"]);
|
|
15
|
+
const DEFAULT_UPDATE_CHECK = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
policy: "ask",
|
|
18
|
+
intervalHours: 8,
|
|
19
|
+
lastCheckedAt: null,
|
|
20
|
+
lastRemote: null,
|
|
21
|
+
lastStatus: null,
|
|
22
|
+
lastErrorCode: null,
|
|
23
|
+
};
|
|
14
24
|
|
|
15
25
|
/** manifest 文件的绝对路径。 */
|
|
16
26
|
export function manifestPath(target) {
|
|
@@ -26,7 +36,82 @@ export function readManifest(target) {
|
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* 归一化启动更新检查配置。
|
|
41
|
+
*
|
|
42
|
+
* 用户策略字段启用保守默认值;缓存字段只保留结构化摘要,避免把网络错误细节写入项目。
|
|
43
|
+
*
|
|
44
|
+
* @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
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeUpdateCheck(value) {
|
|
48
|
+
const raw = value && typeof value === "object" ? value : {};
|
|
49
|
+
const policy = UPDATE_POLICIES.has(raw.policy) ? raw.policy : DEFAULT_UPDATE_CHECK.policy;
|
|
50
|
+
const interval = Number(raw.intervalHours);
|
|
51
|
+
const intervalHours = Number.isFinite(interval) && interval >= 0
|
|
52
|
+
? interval
|
|
53
|
+
: DEFAULT_UPDATE_CHECK.intervalHours;
|
|
54
|
+
const lastRemote = raw.lastRemote && typeof raw.lastRemote === "object"
|
|
55
|
+
? {
|
|
56
|
+
latest: typeof raw.lastRemote.latest === "string" ? raw.lastRemote.latest : null,
|
|
57
|
+
beta: typeof raw.lastRemote.beta === "string" ? raw.lastRemote.beta : null,
|
|
58
|
+
}
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_UPDATE_CHECK.enabled,
|
|
63
|
+
policy,
|
|
64
|
+
intervalHours,
|
|
65
|
+
lastCheckedAt: typeof raw.lastCheckedAt === "string" ? raw.lastCheckedAt : null,
|
|
66
|
+
lastRemote,
|
|
67
|
+
lastStatus: typeof raw.lastStatus === "string" ? raw.lastStatus : null,
|
|
68
|
+
lastErrorCode: typeof raw.lastErrorCode === "string" ? raw.lastErrorCode : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 读取 manifest 里的启动更新检查配置。
|
|
74
|
+
*
|
|
75
|
+
* @param {string} target 目标项目根
|
|
76
|
+
* @returns {ReturnType<typeof normalizeUpdateCheck>} 归一化后的配置
|
|
77
|
+
*/
|
|
78
|
+
export function readUpdateCheck(target) {
|
|
79
|
+
return normalizeUpdateCheck(readManifest(target)?.updateCheck);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 写入启动更新检查配置,保留 manifest 其它安装清单字段。
|
|
84
|
+
*
|
|
85
|
+
* @param {string} target 目标项目根
|
|
86
|
+
* @param {object} patch 要合并进 updateCheck 的字段
|
|
87
|
+
* @returns {object} 写入后的 manifest
|
|
88
|
+
*/
|
|
89
|
+
export function writeUpdateCheck(target, patch) {
|
|
90
|
+
const current = readManifest(target) || {};
|
|
91
|
+
const updateCheck = normalizeUpdateCheck({
|
|
92
|
+
...normalizeUpdateCheck(current.updateCheck),
|
|
93
|
+
...(patch || {}),
|
|
94
|
+
});
|
|
95
|
+
const next = { ...current, updateCheck };
|
|
96
|
+
fs.writeFileSync(manifestPath(target), JSON.stringify(next, null, 2) + "\n");
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 写入 manifest。
|
|
102
|
+
*
|
|
103
|
+
* 若调用方没有显式传入 `updateCheck`,保留目标项目已有策略与缓存,避免全装重写时覆盖用户选择。
|
|
104
|
+
*
|
|
105
|
+
* @param {string} target 目标项目根
|
|
106
|
+
* @param {object} data manifest 新内容
|
|
107
|
+
*/
|
|
30
108
|
export function writeManifest(target, data) {
|
|
31
|
-
|
|
109
|
+
const current = readManifest(target);
|
|
110
|
+
const next = { ...data };
|
|
111
|
+
if (Object.prototype.hasOwnProperty.call(data, "updateCheck")) {
|
|
112
|
+
next.updateCheck = normalizeUpdateCheck(data.updateCheck);
|
|
113
|
+
} else {
|
|
114
|
+
next.updateCheck = normalizeUpdateCheck(current?.updateCheck);
|
|
115
|
+
}
|
|
116
|
+
fs.writeFileSync(manifestPath(target), JSON.stringify(next, null, 2) + "\n");
|
|
32
117
|
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readManifest, readUpdateCheck, writeUpdateCheck } from "./manifest.js";
|
|
5
|
+
import { fetchPackageDistTags, getUpdateRecommendation } from "./update-check.js";
|
|
6
|
+
import { isRunningViaNpx } from "./runtime-env.js";
|
|
7
|
+
import { flowerVersion, trellisVersion } from "./versions.js";
|
|
8
|
+
|
|
9
|
+
/** 上游 trellis update 支持的批量冲突处理参数。 */
|
|
10
|
+
const CONFLICT_FLAGS = new Set(["-f", "--force", "-s", "--skip-all", "-n", "--create-new"]);
|
|
11
|
+
|
|
12
|
+
/** 给命令建议使用的保守 shell 引号。 */
|
|
13
|
+
function shellQuote(value) {
|
|
14
|
+
const text = String(value || "");
|
|
15
|
+
if (/^[A-Za-z0-9_./:=@+-]+$/.test(text)) return text;
|
|
16
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 读取目标项目的 `.trellis/.version`。 */
|
|
20
|
+
function readProjectTrellisVersion(target) {
|
|
21
|
+
try {
|
|
22
|
+
const text = fs.readFileSync(path.join(target, ".trellis", ".version"), "utf8").trim();
|
|
23
|
+
return text || null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 判断远程探测缓存是否仍在 interval 内。 */
|
|
30
|
+
function isRemoteCacheFresh(updateCheck, now = new Date()) {
|
|
31
|
+
if (!updateCheck.lastCheckedAt) return false;
|
|
32
|
+
const checkedAt = new Date(updateCheck.lastCheckedAt).getTime();
|
|
33
|
+
if (!Number.isFinite(checkedAt)) return false;
|
|
34
|
+
return now.getTime() - checkedAt < updateCheck.intervalHours * 60 * 60 * 1000;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 检查目标目录 git 工作区是否 clean。 */
|
|
38
|
+
function gitSafety(target) {
|
|
39
|
+
const common = {
|
|
40
|
+
cwd: target,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
shell: process.platform === "win32",
|
|
43
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
44
|
+
timeout: 1500,
|
|
45
|
+
};
|
|
46
|
+
const root = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], common);
|
|
47
|
+
if (root.error || root.status !== 0 || String(root.stdout || "").trim() !== "true") {
|
|
48
|
+
return { clean: false, reason: "not_git_repo" };
|
|
49
|
+
}
|
|
50
|
+
const status = spawnSync("git", ["status", "--porcelain"], common);
|
|
51
|
+
if (status.error || status.status !== 0) return { clean: false, reason: "git_status_failed" };
|
|
52
|
+
const dirty = String(status.stdout || "")
|
|
53
|
+
.split(/\r?\n/)
|
|
54
|
+
.filter((line) => line.trim());
|
|
55
|
+
return dirty.length
|
|
56
|
+
? { clean: false, reason: "dirty_worktree", dirtyCount: dirty.length }
|
|
57
|
+
: { clean: true, reason: null, dirtyCount: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 递归查找 active / in_progress Trellis 任务。 */
|
|
61
|
+
function hasActiveTask(target) {
|
|
62
|
+
const tasksDir = path.join(target, ".trellis", "tasks");
|
|
63
|
+
const stack = [tasksDir];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const dir = stack.pop();
|
|
66
|
+
let entries = [];
|
|
67
|
+
try {
|
|
68
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const abs = path.join(dir, entry.name);
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
stack.push(abs);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (entry.name !== "task.json") continue;
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
81
|
+
if (data?.status === "in_progress" || data?.status === "active") {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// 损坏任务文件不阻断版本检查;auto 安全门槛另由可读性结果降级。
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 判断当前 shell 是否可直接调用 flower-trellis 命令。 */
|
|
93
|
+
function hasFlowerCommand() {
|
|
94
|
+
const res = spawnSync("flower-trellis", ["-v"], {
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
shell: process.platform === "win32",
|
|
97
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
98
|
+
timeout: 1500,
|
|
99
|
+
});
|
|
100
|
+
return !res.error && res.status === 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 生成项目更新阶段参数。
|
|
105
|
+
*
|
|
106
|
+
* 默认追加 `--force`,对应 Trellis 交互里的 Apply Overwrite to all。若用户显式透传
|
|
107
|
+
* 其它批量冲突策略,则尊重用户选择,不再追加默认覆盖策略。
|
|
108
|
+
*
|
|
109
|
+
* @param {string[]} forwarded 透传给项目 update 的参数
|
|
110
|
+
* @returns {string[]} 项目 update 透传参数
|
|
111
|
+
*/
|
|
112
|
+
export function projectUpdateForwardArgs(forwarded = []) {
|
|
113
|
+
const hasConflictStrategy = forwarded.some((arg) => CONFLICT_FLAGS.has(arg));
|
|
114
|
+
return hasConflictStrategy ? [...forwarded] : ["--force", ...forwarded];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 生成手动项目更新命令。
|
|
119
|
+
*
|
|
120
|
+
* @param {string} target 目标项目根
|
|
121
|
+
* @param {string[]} forwarded 透传参数
|
|
122
|
+
* @returns {string} 可执行命令
|
|
123
|
+
*/
|
|
124
|
+
export function projectUpdateCommand(target, forwarded = []) {
|
|
125
|
+
const args = [
|
|
126
|
+
"flower-trellis",
|
|
127
|
+
"update",
|
|
128
|
+
"--target",
|
|
129
|
+
target,
|
|
130
|
+
"--no-update-check",
|
|
131
|
+
...projectUpdateForwardArgs(forwarded),
|
|
132
|
+
];
|
|
133
|
+
return args.map(shellQuote).join(" ");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 生成 self-update 命令。
|
|
138
|
+
*
|
|
139
|
+
* @param {string} target 目标项目根
|
|
140
|
+
* @param {{projectOnly?: boolean}} options 命令选项
|
|
141
|
+
* @returns {string} 可执行命令
|
|
142
|
+
*/
|
|
143
|
+
export function selfUpdateCommand(target, options = {}) {
|
|
144
|
+
const args = ["flower-trellis", "self-update", "--target", target, "--yes"];
|
|
145
|
+
if (options.projectOnly) args.push("--project-only");
|
|
146
|
+
return args.map(shellQuote).join(" ");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 根据 policy 和安全检查结果生成 AI 动作指令。 */
|
|
150
|
+
function actionForPolicy(policy, command, safety) {
|
|
151
|
+
if (policy === "notify") {
|
|
152
|
+
return {
|
|
153
|
+
mode: "notify",
|
|
154
|
+
instruction: "只告知用户发现更新和手动命令,不要主动询问或执行。",
|
|
155
|
+
command,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (policy === "auto" && safety.safe) {
|
|
159
|
+
return {
|
|
160
|
+
mode: "auto",
|
|
161
|
+
instruction: "安全条件满足,可以直接执行受控更新命令。",
|
|
162
|
+
command,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
mode: "ask",
|
|
167
|
+
instruction: "先询问用户是否执行更新;用户确认后再运行推荐命令。",
|
|
168
|
+
command,
|
|
169
|
+
downgradedFromAuto: policy === "auto",
|
|
170
|
+
downgradeReasons: policy === "auto" ? safety.reasons : [],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 计算 auto 策略安全门槛。 */
|
|
175
|
+
export function safetyState(target, status, command) {
|
|
176
|
+
const git = gitSafety(target);
|
|
177
|
+
const activeTask = hasActiveTask(target);
|
|
178
|
+
const flowerCommand = hasFlowerCommand();
|
|
179
|
+
const reasons = [];
|
|
180
|
+
if (!git.clean) reasons.push(git.reason);
|
|
181
|
+
if (activeTask) reasons.push("active_task");
|
|
182
|
+
if (!flowerCommand) reasons.push("flower_command_missing");
|
|
183
|
+
if (!command) reasons.push("missing_command");
|
|
184
|
+
if (process.env.FLOWER_NO_UPDATE_CHECK) reasons.push("disabled_by_env");
|
|
185
|
+
if (!["update_available", "project_out_of_sync"].includes(status)) reasons.push("status_not_actionable");
|
|
186
|
+
return {
|
|
187
|
+
safe: reasons.length === 0,
|
|
188
|
+
reasons,
|
|
189
|
+
git,
|
|
190
|
+
activeTask,
|
|
191
|
+
flowerCommand,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 构建启动自更新检查结果。
|
|
197
|
+
*
|
|
198
|
+
* @param {string} target 目标项目根
|
|
199
|
+
* @param {{writeCache?: boolean, forceRemote?: boolean}} options 检查选项
|
|
200
|
+
* @returns {Promise<object>} 结构化检查结果
|
|
201
|
+
*/
|
|
202
|
+
export async function buildSelfCheck(target, options = {}) {
|
|
203
|
+
const writeCache = options.writeCache !== false;
|
|
204
|
+
const forceRemote = options.forceRemote === true;
|
|
205
|
+
const now = new Date();
|
|
206
|
+
const absoluteTarget = path.resolve(target);
|
|
207
|
+
const trellisDir = path.join(absoluteTarget, ".trellis");
|
|
208
|
+
const manifest = readManifest(absoluteTarget);
|
|
209
|
+
const updateCheck = readUpdateCheck(absoluteTarget);
|
|
210
|
+
const currentFlower = flowerVersion();
|
|
211
|
+
const currentTrellis = trellisVersion();
|
|
212
|
+
const projectTrellis = readProjectTrellisVersion(absoluteTarget);
|
|
213
|
+
const projectFlower = typeof manifest?.flowerVersion === "string" ? manifest.flowerVersion : null;
|
|
214
|
+
|
|
215
|
+
const base = {
|
|
216
|
+
status: "up_to_date",
|
|
217
|
+
checkedAt: now.toISOString(),
|
|
218
|
+
target: absoluteTarget,
|
|
219
|
+
policy: updateCheck.policy,
|
|
220
|
+
updateCheck,
|
|
221
|
+
current: {
|
|
222
|
+
flowerVersion: currentFlower,
|
|
223
|
+
bundledTrellisVersion: currentTrellis,
|
|
224
|
+
},
|
|
225
|
+
project: {
|
|
226
|
+
flowerVersion: projectFlower,
|
|
227
|
+
trellisVersion: projectTrellis,
|
|
228
|
+
manifestPresent: Boolean(manifest),
|
|
229
|
+
},
|
|
230
|
+
remote: {
|
|
231
|
+
tags: updateCheck.lastRemote,
|
|
232
|
+
fromCache: false,
|
|
233
|
+
skipped: false,
|
|
234
|
+
errorCode: null,
|
|
235
|
+
},
|
|
236
|
+
recommendation: null,
|
|
237
|
+
commands: {},
|
|
238
|
+
safety: null,
|
|
239
|
+
ai: null,
|
|
240
|
+
reason: null,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (!fs.existsSync(trellisDir)) {
|
|
244
|
+
return { ...base, status: "skipped", reason: "not_trellis_project" };
|
|
245
|
+
}
|
|
246
|
+
if (process.env.FLOWER_NO_UPDATE_CHECK || !updateCheck.enabled || updateCheck.policy === "off") {
|
|
247
|
+
return { ...base, status: "disabled", reason: "disabled" };
|
|
248
|
+
}
|
|
249
|
+
if (isRunningViaNpx(import.meta.url)) {
|
|
250
|
+
return { ...base, status: "skipped", reason: "npx_runtime" };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const projectOutOfSync =
|
|
254
|
+
(projectFlower && projectFlower !== currentFlower) ||
|
|
255
|
+
(projectTrellis && projectTrellis !== currentTrellis);
|
|
256
|
+
|
|
257
|
+
if (projectOutOfSync) {
|
|
258
|
+
const command = selfUpdateCommand(absoluteTarget, { projectOnly: true });
|
|
259
|
+
const result = {
|
|
260
|
+
...base,
|
|
261
|
+
status: "project_out_of_sync",
|
|
262
|
+
reason: "local_version_mismatch",
|
|
263
|
+
commands: {
|
|
264
|
+
recommended: command,
|
|
265
|
+
projectUpdate: projectUpdateCommand(absoluteTarget),
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const safety = safetyState(absoluteTarget, result.status, command);
|
|
269
|
+
result.safety = safety;
|
|
270
|
+
result.ai = actionForPolicy(updateCheck.policy, command, safety);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let tags = updateCheck.lastRemote;
|
|
275
|
+
if (!forceRemote && isRemoteCacheFresh(updateCheck, now)) {
|
|
276
|
+
const recommendation = getUpdateRecommendation(currentFlower, tags);
|
|
277
|
+
if (recommendation) {
|
|
278
|
+
const command = selfUpdateCommand(absoluteTarget);
|
|
279
|
+
const result = {
|
|
280
|
+
...base,
|
|
281
|
+
status: "update_available",
|
|
282
|
+
reason: "cached_remote_update",
|
|
283
|
+
remote: { ...base.remote, tags, fromCache: true, skipped: true },
|
|
284
|
+
recommendation,
|
|
285
|
+
commands: { recommended: command, npm: recommendation.command },
|
|
286
|
+
};
|
|
287
|
+
const safety = safetyState(absoluteTarget, result.status, command);
|
|
288
|
+
result.safety = safety;
|
|
289
|
+
result.ai = actionForPolicy(updateCheck.policy, command, safety);
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
...base,
|
|
294
|
+
status: "skipped",
|
|
295
|
+
reason: "interval_not_elapsed",
|
|
296
|
+
remote: { ...base.remote, tags, fromCache: true, skipped: true },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
tags = await fetchPackageDistTags();
|
|
301
|
+
if (!tags) {
|
|
302
|
+
if (writeCache && manifest) {
|
|
303
|
+
writeUpdateCheck(absoluteTarget, {
|
|
304
|
+
lastCheckedAt: now.toISOString(),
|
|
305
|
+
lastStatus: "offline",
|
|
306
|
+
lastErrorCode: "fetch_failed",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
...base,
|
|
311
|
+
status: "offline",
|
|
312
|
+
reason: "fetch_failed",
|
|
313
|
+
remote: { ...base.remote, tags: updateCheck.lastRemote, errorCode: "fetch_failed" },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const recommendation = getUpdateRecommendation(currentFlower, tags);
|
|
318
|
+
const status = recommendation ? "update_available" : "up_to_date";
|
|
319
|
+
if (writeCache && manifest) {
|
|
320
|
+
writeUpdateCheck(absoluteTarget, {
|
|
321
|
+
lastCheckedAt: now.toISOString(),
|
|
322
|
+
lastRemote: tags,
|
|
323
|
+
lastStatus: status,
|
|
324
|
+
lastErrorCode: null,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!recommendation) {
|
|
329
|
+
return {
|
|
330
|
+
...base,
|
|
331
|
+
status,
|
|
332
|
+
remote: { ...base.remote, tags },
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const command = selfUpdateCommand(absoluteTarget);
|
|
337
|
+
const result = {
|
|
338
|
+
...base,
|
|
339
|
+
status,
|
|
340
|
+
remote: { ...base.remote, tags },
|
|
341
|
+
recommendation,
|
|
342
|
+
commands: {
|
|
343
|
+
recommended: command,
|
|
344
|
+
npm: recommendation.command,
|
|
345
|
+
projectUpdate: projectUpdateCommand(absoluteTarget),
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
const safety = safetyState(absoluteTarget, result.status, command);
|
|
349
|
+
result.safety = safety;
|
|
350
|
+
result.ai = actionForPolicy(updateCheck.policy, command, safety);
|
|
351
|
+
return result;
|
|
352
|
+
}
|