flower-trellis 0.6.10-beta.7 → 0.6.10-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/enhancements/0.6/.agents/skills/trellis-task-brief/SKILL.md +17 -16
- package/enhancements/0.6/.claude/skills/trellis-task-brief/SKILL.md +17 -16
- package/enhancements/0.6/overrides/conflicts.json +2 -2
- package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/summary-shape-content.md +1 -1
- package/enhancements/MANIFEST.json +2 -2
- package/package.json +3 -3
- package/src/cli.js +6 -0
- package/src/commands/update-all.js +289 -0
- package/src/constants.js +1 -0
- package/src/lib/cli-args.js +21 -1
- package/src/lib/self-check.js +13 -4
- package/src/lib/update-all-projects.js +160 -0
package/README.md
CHANGED
|
@@ -100,6 +100,7 @@ flower-trellis -v
|
|
|
100
100
|
| `trellis` | 项目级关闭、恢复或检查全部平台中的 Trellis 集成 |
|
|
101
101
|
| `self-check` | 输出启动更新检查 JSON,供 Codex / Claude Code hook 和 AI 自动化读取 |
|
|
102
102
|
| `self-update` | 受控升级 flower-trellis 并对目标项目执行完整 `flower-trellis update` 重叠加 |
|
|
103
|
+
| `update-all` | 扫描指定父目录并组合显式项目,批量追平 Flower/Trellis 版本 |
|
|
103
104
|
| `update-check` | 管理 `.flower/settings.json` 内的启动更新策略和提示节流 |
|
|
104
105
|
| `telemetry` | 查询、启用或停用用户级匿名安装遥测 |
|
|
105
106
|
| `plugin` | 管理 Flower Plugin、Marketplace 来源、GitLab 授权和作者校验 |
|
|
@@ -116,6 +117,7 @@ flower-trellis -v
|
|
|
116
117
|
| `--skills <a,b,...>` | 只安装指定技能(可省略 `trellis-` 前缀) |
|
|
117
118
|
| `--variant <old\|0.5\|0.6>` | 强制指定强化包变体(默认按 `.trellis/.version` 自动选) |
|
|
118
119
|
| `--target <dir>` | 目标目录(默认当前目录) |
|
|
120
|
+
| `--root <dir>` | `update-all` 扫描的父目录,可重复传入 |
|
|
119
121
|
| `--no-update-check` | 本次跳过 flower-trellis 新版本检测(等价环境变量 `FLOWER_NO_UPDATE_CHECK=1`) |
|
|
120
122
|
| `--backup-retention <n>` | `update` 成功后保留最近 n 份 `.trellis/.backup-<timestamp>` 快照(默认 1,`0` 表示本次不清理) |
|
|
121
123
|
|
|
@@ -363,6 +365,15 @@ flower-trellis self-update --target . --dry-run
|
|
|
363
365
|
flower-trellis self-update --target . --yes
|
|
364
366
|
```
|
|
365
367
|
|
|
368
|
+
批量更新集中目录和零散项目时,可重复组合 `--root` 与 `--target`,真实路径会去重:
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
flower-trellis update-all --root ~/projects --target /work/other-project --dry-run
|
|
372
|
+
flower-trellis update-all --root ~/projects --target /work/other-project --yes
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`--dry-run` 只读列出各项目预检结果,不安装 CLI,也不写项目或更新缓存;真实执行必须加 `--yes`。命令先检查每个项目的 Git clean 状态、活动任务和版本证据;脏工作区、活动任务及无法安全比较版本的项目会跳过并标明原因。远端版本无法确认时只追平到本机已安装版本。全局 Flower CLI 最多安装一次,随后按项目顺序执行完整 `update`;某项目失败仍继续后续项目。末尾汇总逐项结果,有跳过或失败时退出码为 `1`。命令不修改应用版本,也不自动提交或推送;更新后需按各 Git 仓库分别确认变更。
|
|
376
|
+
|
|
366
377
|
项目重叠加阶段默认等价于 Trellis 交互里的 **Apply Overwrite to all**:
|
|
367
378
|
|
|
368
379
|
```bash
|
|
@@ -18,7 +18,8 @@ description: "从最新 prd.md、design.md、implement.md 生成、刷新、校
|
|
|
18
18
|
- 每次运行都重新读取最新 `prd.md`、`design.md if present`、`implement.md if present`。
|
|
19
19
|
- 已存在的 `brief.md` 不能作为跳过同步的理由。
|
|
20
20
|
- `brief.md` 必须以三件套为准覆盖旧内容;无法从三件套追溯的旧内容不能保留为事实。
|
|
21
|
-
- 不要在 `brief.md`
|
|
21
|
+
- 不要在 `brief.md` 里发明三件套没有表达的新需求。必填字段缺失时写“未明确”,并提示应补充三件套;没有明确技术机制时省略 `Technical Overview`,没有具体风险时省略 `Risks`。
|
|
22
|
+
- `Goal` 保持一句话,`Scope` 只写本轮纳入的行为或改动对象;延期事项归入 `Non-Goals`,不另设栏目,也不把范围、方案和验收写成同义复述。
|
|
22
23
|
- 写回 `brief.md` 后,必须在当前对话中展示 brief 正文;不要只给文件路径。
|
|
23
24
|
- Phase 1.4 前必须展示完整 brief。除上方经 runner 校验的 Auto-Loop Action 例外外,默认等待用户确认后再运行 `task.py start`;只有用户明确把当前任务或最终 Brief 与“展示后直接开始 / 不用再次确认 / 视为已确认”绑定时,才可在范围未变化的前提下免除第二次确认。
|
|
24
25
|
- “开始做吧”“按你建议来”“可以创建任务”等普通实现或建任务意图不是 Brief 预授权,不能据此跳过确认。
|
|
@@ -45,12 +46,12 @@ description: "从最新 prd.md、design.md、implement.md 生成、刷新、校
|
|
|
45
46
|
- 若最终内容扩大范围、仍有未解决 Open Questions,或新增权限、安全、隐私、生产、费用、真实数据、破坏性公开契约、外部系统边界,则预授权失效。
|
|
46
47
|
5. 从最新三件套提取:
|
|
47
48
|
- `Goal`:任务目标一句话。
|
|
48
|
-
- `Scope
|
|
49
|
-
- `Non-Goals
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
52
|
-
- `Risks
|
|
53
|
-
- `Acceptance
|
|
49
|
+
- `Scope`:本轮纳入的行为或改动对象;不重复技术原理、取舍或验收。
|
|
50
|
+
- `Non-Goals`:明确不做或延后的范围。
|
|
51
|
+
- `Technical Overview`:来源材料已明确技术机制时,简述触发入口、主要处理或数据流、关键校验或状态变化及结果;不复制完整设计或实施步骤,没有依据时省略整节。
|
|
52
|
+
- `Context & Decisions`:影响实施判断的既有事实、关键文件或模块、入口、硬约束,以及已收敛且会改变产品行为、范围、UX、兼容性、风险或核心架构的取舍及原因;不复制完整决策台账,也不重复概览中的流程。
|
|
53
|
+
- `Risks`:仍需在实现或验证中关注的具体风险;没有内容时省略整节。
|
|
54
|
+
- `Acceptance`:主要可验证的验收标准;负向范围声明仅在需要回归验证时保留。
|
|
54
55
|
- `Next Step`:只写进入下一阶段后的一个直接动作,不展开完整实施计划。
|
|
55
56
|
6. 写回 `<task>/brief.md`。如果文件已存在,仍用最新三件套派生内容覆盖旧正文。
|
|
56
57
|
7. 在对话中展示 brief 正文,并说明来源文件:
|
|
@@ -69,23 +70,23 @@ description: "从最新 prd.md、design.md、implement.md 生成、刷新、校
|
|
|
69
70
|
|
|
70
71
|
## Scope
|
|
71
72
|
|
|
72
|
-
-
|
|
73
|
+
- <本轮纳入的行为或改动对象,不复述原理、取舍或验收>
|
|
73
74
|
|
|
74
75
|
## Non-Goals
|
|
75
76
|
|
|
76
|
-
-
|
|
77
|
+
- <本轮明确不做或延后的事情>
|
|
77
78
|
|
|
78
|
-
##
|
|
79
|
+
## Technical Overview
|
|
79
80
|
|
|
80
|
-
-
|
|
81
|
+
- <仅在规划材料明确技术机制时,概述入口、处理流、关键校验和结果;否则省略整节>
|
|
81
82
|
|
|
82
|
-
##
|
|
83
|
+
## Context & Decisions
|
|
83
84
|
|
|
84
|
-
-
|
|
85
|
+
- <影响实施的既有事实、入口、约束,以及关键取舍与原因,不复述流程>
|
|
85
86
|
|
|
86
|
-
## Risks
|
|
87
|
+
## Risks
|
|
87
88
|
|
|
88
|
-
-
|
|
89
|
+
- <仅在存在具体风险时生成;否则省略整个 section>
|
|
89
90
|
|
|
90
91
|
## Acceptance
|
|
91
92
|
|
|
@@ -118,7 +119,7 @@ Phase 1.4 review 前:
|
|
|
118
119
|
已按你对当前 Brief 的明确预授权完成复核;范围未扩大、无未解决问题,继续启动任务。
|
|
119
120
|
```
|
|
120
121
|
|
|
121
|
-
规划评审或用户明确要求查看时,完整展示 `brief.md` 正文,不压缩、不摘录、不改写字段结构,保留 Non-Goals
|
|
122
|
+
规划评审或用户明确要求查看时,完整展示 `brief.md` 正文,不压缩、不摘录、不改写字段结构,保留 Non-Goals、技术概览(若有)、关键决定和验收条件。
|
|
122
123
|
|
|
123
124
|
## 不要做
|
|
124
125
|
|
|
@@ -18,7 +18,8 @@ description: "从最新 prd.md、design.md、implement.md 生成、刷新、校
|
|
|
18
18
|
- 每次运行都重新读取最新 `prd.md`、`design.md if present`、`implement.md if present`。
|
|
19
19
|
- 已存在的 `brief.md` 不能作为跳过同步的理由。
|
|
20
20
|
- `brief.md` 必须以三件套为准覆盖旧内容;无法从三件套追溯的旧内容不能保留为事实。
|
|
21
|
-
- 不要在 `brief.md`
|
|
21
|
+
- 不要在 `brief.md` 里发明三件套没有表达的新需求。必填字段缺失时写“未明确”,并提示应补充三件套;没有明确技术机制时省略 `Technical Overview`,没有具体风险时省略 `Risks`。
|
|
22
|
+
- `Goal` 保持一句话,`Scope` 只写本轮纳入的行为或改动对象;延期事项归入 `Non-Goals`,不另设栏目,也不把范围、方案和验收写成同义复述。
|
|
22
23
|
- 写回 `brief.md` 后,必须在当前对话中展示 brief 正文;不要只给文件路径。
|
|
23
24
|
- Phase 1.4 前必须展示完整 brief。除上方经 runner 校验的 Auto-Loop Action 例外外,默认等待用户确认后再运行 `task.py start`;只有用户明确把当前任务或最终 Brief 与“展示后直接开始 / 不用再次确认 / 视为已确认”绑定时,才可在范围未变化的前提下免除第二次确认。
|
|
24
25
|
- “开始做吧”“按你建议来”“可以创建任务”等普通实现或建任务意图不是 Brief 预授权,不能据此跳过确认。
|
|
@@ -45,12 +46,12 @@ description: "从最新 prd.md、design.md、implement.md 生成、刷新、校
|
|
|
45
46
|
- 若最终内容扩大范围、仍有未解决 Open Questions,或新增权限、安全、隐私、生产、费用、真实数据、破坏性公开契约、外部系统边界,则预授权失效。
|
|
46
47
|
5. 从最新三件套提取:
|
|
47
48
|
- `Goal`:任务目标一句话。
|
|
48
|
-
- `Scope
|
|
49
|
-
- `Non-Goals
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
52
|
-
- `Risks
|
|
53
|
-
- `Acceptance
|
|
49
|
+
- `Scope`:本轮纳入的行为或改动对象;不重复技术原理、取舍或验收。
|
|
50
|
+
- `Non-Goals`:明确不做或延后的范围。
|
|
51
|
+
- `Technical Overview`:来源材料已明确技术机制时,简述触发入口、主要处理或数据流、关键校验或状态变化及结果;不复制完整设计或实施步骤,没有依据时省略整节。
|
|
52
|
+
- `Context & Decisions`:影响实施判断的既有事实、关键文件或模块、入口、硬约束,以及已收敛且会改变产品行为、范围、UX、兼容性、风险或核心架构的取舍及原因;不复制完整决策台账,也不重复概览中的流程。
|
|
53
|
+
- `Risks`:仍需在实现或验证中关注的具体风险;没有内容时省略整节。
|
|
54
|
+
- `Acceptance`:主要可验证的验收标准;负向范围声明仅在需要回归验证时保留。
|
|
54
55
|
- `Next Step`:只写进入下一阶段后的一个直接动作,不展开完整实施计划。
|
|
55
56
|
6. 写回 `<task>/brief.md`。如果文件已存在,仍用最新三件套派生内容覆盖旧正文。
|
|
56
57
|
7. 在对话中展示 brief 正文,并说明来源文件:
|
|
@@ -69,23 +70,23 @@ description: "从最新 prd.md、design.md、implement.md 生成、刷新、校
|
|
|
69
70
|
|
|
70
71
|
## Scope
|
|
71
72
|
|
|
72
|
-
-
|
|
73
|
+
- <本轮纳入的行为或改动对象,不复述原理、取舍或验收>
|
|
73
74
|
|
|
74
75
|
## Non-Goals
|
|
75
76
|
|
|
76
|
-
-
|
|
77
|
+
- <本轮明确不做或延后的事情>
|
|
77
78
|
|
|
78
|
-
##
|
|
79
|
+
## Technical Overview
|
|
79
80
|
|
|
80
|
-
-
|
|
81
|
+
- <仅在规划材料明确技术机制时,概述入口、处理流、关键校验和结果;否则省略整节>
|
|
81
82
|
|
|
82
|
-
##
|
|
83
|
+
## Context & Decisions
|
|
83
84
|
|
|
84
|
-
-
|
|
85
|
+
- <影响实施的既有事实、入口、约束,以及关键取舍与原因,不复述流程>
|
|
85
86
|
|
|
86
|
-
## Risks
|
|
87
|
+
## Risks
|
|
87
88
|
|
|
88
|
-
-
|
|
89
|
+
- <仅在存在具体风险时生成;否则省略整个 section>
|
|
89
90
|
|
|
90
91
|
## Acceptance
|
|
91
92
|
|
|
@@ -118,7 +119,7 @@ Phase 1.4 review 前:
|
|
|
118
119
|
已按你对当前 Brief 的明确预授权完成复核;范围未扩大、无未解决问题,继续启动任务。
|
|
119
120
|
```
|
|
120
121
|
|
|
121
|
-
规划评审或用户明确要求查看时,完整展示 `brief.md` 正文,不压缩、不摘录、不改写字段结构,保留 Non-Goals
|
|
122
|
+
规划评审或用户明确要求查看时,完整展示 `brief.md` 正文,不压缩、不摘录、不改写字段结构,保留 Non-Goals、技术概览(若有)、关键决定和验收条件。
|
|
122
123
|
|
|
123
124
|
## 不要做
|
|
124
125
|
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
"## Planning Handoff",
|
|
139
139
|
"<!-- BEGIN skill-garden patch brainstorm-planning-readiness v0.6 -->\n8. Load `trellis-task-brief`",
|
|
140
140
|
"Only valid explicit preauthorization for the current final Brief, as defined by `trellis-task-brief`, permits same-turn start within unchanged scope.",
|
|
141
|
-
"The final Brief must cover Goal, Scope, Non-Goals,
|
|
141
|
+
"The final Brief must cover a one-sentence Goal, a Scope limited to included changes, Non-Goals, Context & Decisions, Acceptance, and one-hop Next Step",
|
|
142
142
|
"Ordinary implementation intent expressed before the final artifacts and full Brief are shown authorizes planning only",
|
|
143
143
|
"unresolved items are `- [ ]`",
|
|
144
144
|
"Do not write bare placeholders"
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
"## Planning Handoff",
|
|
163
163
|
"<!-- BEGIN skill-garden patch brainstorm-planning-readiness v0.6 -->\n8. Load `trellis-task-brief`",
|
|
164
164
|
"Only valid explicit preauthorization for the current final Brief, as defined by `trellis-task-brief`, permits same-turn start within unchanged scope.",
|
|
165
|
-
"The final Brief must cover Goal, Scope, Non-Goals,
|
|
165
|
+
"The final Brief must cover a one-sentence Goal, a Scope limited to included changes, Non-Goals, Context & Decisions, Acceptance, and one-hop Next Step",
|
|
166
166
|
"Ordinary implementation intent expressed before the final artifacts and full Brief are shown authorizes planning only",
|
|
167
167
|
"unresolved items are `- [ ]`",
|
|
168
168
|
"Do not write bare placeholders"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
The final Brief must cover Goal, Scope, Non-Goals,
|
|
1
|
+
The final Brief must cover a one-sentence Goal, a Scope limited to included changes, Non-Goals, Context & Decisions, Acceptance, and one-hop Next Step; include Technical Overview only when the planning artifacts define a technical mechanism, and Risks only when relevant.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"syncedAt": "2026-09-
|
|
2
|
+
"syncedAt": "2026-09-28T00:53:21.902Z",
|
|
3
3
|
"syncedFrom": "vendor/skill-garden",
|
|
4
|
-
"sourceCommit": "
|
|
4
|
+
"sourceCommit": "410f810c389bd60fcaf237b05dd4cfde8ee15b36",
|
|
5
5
|
"common": {
|
|
6
6
|
"codexSkills": [
|
|
7
7
|
"aliyun-ops",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flower-trellis",
|
|
3
|
-
"version": "0.6.10-beta.
|
|
3
|
+
"version": "0.6.10-beta.9",
|
|
4
4
|
"description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -70,9 +70,9 @@
|
|
|
70
70
|
"commit-and-tag-version": "^12.7.3"
|
|
71
71
|
},
|
|
72
72
|
"flowerReleaseNotes": {
|
|
73
|
-
"version": "0.6.10-beta.
|
|
73
|
+
"version": "0.6.10-beta.9",
|
|
74
74
|
"source": "CHANGELOG.md",
|
|
75
|
-
"body": "### ✨ 新功能 Features\n\n* **flower:**
|
|
75
|
+
"body": "### ✨ 新功能 Features\n\n* **flower:** 支持批量更新多个项目 ([ef32c2e](https://github.com/SilentFlower/flower-trellis/commit/ef32c2e47dcf326c83aceb67549e3374fc68fa01))",
|
|
76
76
|
"truncated": false
|
|
77
77
|
},
|
|
78
78
|
"optionalDependencies": {
|
package/src/cli.js
CHANGED
|
@@ -78,6 +78,7 @@ function printHelp() {
|
|
|
78
78
|
flower-trellis self-check --json [--manual] [--target <dir>]
|
|
79
79
|
输出启动更新检查 JSON
|
|
80
80
|
flower-trellis self-update --target <dir> --yes 自更新 + 项目重叠加
|
|
81
|
+
flower-trellis update-all --root <dir> --target <dir> 批量发现并更新多个项目
|
|
81
82
|
flower-trellis update-check <get|set|disable|enable|snooze|skip|reset>
|
|
82
83
|
管理启动更新策略
|
|
83
84
|
flower-trellis telemetry <status|enable|disable> 管理匿名安装遥测
|
|
@@ -98,6 +99,7 @@ flower 自有 flag:
|
|
|
98
99
|
--skills <a,b,...> 只装指定技能(支持去 trellis- 前缀匹配)
|
|
99
100
|
--variant <old|0.5|0.6> 强制强化包变体(默认按 .trellis/.version 自动选)
|
|
100
101
|
--target <dir> 目标目录(默认当前目录)
|
|
102
|
+
--root <dir> update-all 扫描目录(可重复)
|
|
101
103
|
--no-update-check 本次跳过 flower-trellis 新版本检测(等价 FLOWER_NO_UPDATE_CHECK=1)
|
|
102
104
|
--backup-retention <n> update 成功后保留最近 n 份升级备份(默认 ${DEFAULT_UPDATE_BACKUP_RETENTION},0=不清理)
|
|
103
105
|
|
|
@@ -172,6 +174,10 @@ async function main() {
|
|
|
172
174
|
} else if (cmd === "self-update") {
|
|
173
175
|
const { selfUpdate } = await import("./commands/self-update.js");
|
|
174
176
|
await selfUpdate(ctx);
|
|
177
|
+
} else if (cmd === "update-all") {
|
|
178
|
+
const { updateAll } = await import("./commands/update-all.js");
|
|
179
|
+
const code = await updateAll(ctx);
|
|
180
|
+
if (code !== 0) process.exitCode = code;
|
|
175
181
|
} else if (cmd === "update-check") {
|
|
176
182
|
const { updateCheck } = await import("./commands/update-check.js");
|
|
177
183
|
await updateCheck(ctx);
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { hasHelpFlag } from "../lib/cli-args.js";
|
|
5
|
+
import { gitSafety } from "../lib/self-check.js";
|
|
6
|
+
import { readProjectVersions } from "../lib/telemetry-context.js";
|
|
7
|
+
import {
|
|
8
|
+
compareVersions,
|
|
9
|
+
fetchPackageDistTags,
|
|
10
|
+
getUpdateRecommendation,
|
|
11
|
+
installFlowerVersion,
|
|
12
|
+
} from "../lib/update-check.js";
|
|
13
|
+
import { ProjectStore } from "../plugin/state/project-store.js";
|
|
14
|
+
import { discoverFlowerProjects, gitRepositoryRoot, projectTaskSafety } from "../lib/update-all-projects.js";
|
|
15
|
+
import { flowerVersion, trellisVersion } from "../lib/versions.js";
|
|
16
|
+
|
|
17
|
+
/** 打印批量更新命令帮助。 */
|
|
18
|
+
function printUpdateAllHelp() {
|
|
19
|
+
console.log(`flower-trellis update-all — 批量更新多个 Flower 项目
|
|
20
|
+
|
|
21
|
+
用法:
|
|
22
|
+
flower-trellis update-all --root <dir> [--root <dir>...] --dry-run
|
|
23
|
+
flower-trellis update-all --target <dir> [--target <dir>...] --yes
|
|
24
|
+
flower-trellis update-all --root <dir> --target <dir> --yes
|
|
25
|
+
|
|
26
|
+
选项:
|
|
27
|
+
--root <dir> 扫描指定父目录,可重复
|
|
28
|
+
--target <dir> 指定已安装 Flower 的项目,可重复
|
|
29
|
+
--dry-run 只读预览,不安装 CLI 或修改项目与缓存
|
|
30
|
+
-y, --yes 确认真实更新
|
|
31
|
+
--no-update-check 不查询远端,仅追平已安装的 Flower/Trellis 版本
|
|
32
|
+
|
|
33
|
+
脏 Git 工作区、活动任务和版本证据缺失的项目会跳过;有跳过或失败时退出码为 1。
|
|
34
|
+
远端无法确认时仅使用本机版本。项目更新后需分别确认 Git 变更,本命令不提交或推送。`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 定位 npm 全局安装的入口,保证后续子进程确实使用安装后的包。 */
|
|
38
|
+
function installedCliScript() {
|
|
39
|
+
const result = spawnSync("npm", ["root", "-g"], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
shell: process.platform === "win32",
|
|
43
|
+
});
|
|
44
|
+
if (result.error || result.status !== 0) {
|
|
45
|
+
throw new Error(`无法定位 npm 全局目录(${result.error?.message || result.stderr?.trim() || `退出码 ${result.status ?? 1}`})`);
|
|
46
|
+
}
|
|
47
|
+
const script = path.join(String(result.stdout).trim(), "flower-trellis", "bin", "flower-trellis.js");
|
|
48
|
+
if (!fs.existsSync(script)) throw new Error(`全局 Flower CLI 不存在: ${script}`);
|
|
49
|
+
return script;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 通过 Node 参数数组启动已安装 CLI,路径不交给 shell 拼接。 */
|
|
53
|
+
function runInstalledCli(script, args, options = {}) {
|
|
54
|
+
return spawnSync(process.execPath, [script, ...args], {
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
env: { ...process.env, FLOWER_NO_UPDATE_CHECK: "1", ...options.env },
|
|
57
|
+
encoding: options.capture ? "utf8" : undefined,
|
|
58
|
+
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 从新进程读取实际 CLI 版本,避免全局安装后继续信任旧模块缓存。 */
|
|
63
|
+
function inspectInstalledCli() {
|
|
64
|
+
const script = installedCliScript();
|
|
65
|
+
const packageRoot = path.dirname(path.dirname(script));
|
|
66
|
+
const result = runInstalledCli(script, ["self-check", "--json", "--target", packageRoot, "--no-update-check"], {
|
|
67
|
+
cwd: packageRoot,
|
|
68
|
+
capture: true,
|
|
69
|
+
env: { FLOWER_NO_TELEMETRY: "1" },
|
|
70
|
+
});
|
|
71
|
+
if (result.error || result.status !== 0) {
|
|
72
|
+
throw new Error(`已安装 CLI 版本核对失败(${result.error?.message || result.stderr?.trim() || `退出码 ${result.status ?? 1}`})`);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const current = JSON.parse(result.stdout).current;
|
|
76
|
+
if (!current?.flowerVersion || !current?.bundledTrellisVersion) throw new Error("缺少版本字段");
|
|
77
|
+
return { flower: current.flowerVersion, trellis: current.bundledTrellisVersion, script };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`已安装 CLI 版本结果无效(${error.message})`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 使用已安装 CLI 完整更新一个项目。 */
|
|
84
|
+
function runProjectUpdate(target, installed) {
|
|
85
|
+
return runInstalledCli(installed.script, ["update", "--target", target, "--no-update-check", "--force", "-y"], { cwd: target });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 将子进程失败转换为可读原因。 */
|
|
89
|
+
function processFailure(result) {
|
|
90
|
+
return result?.error?.message || `退出码 ${result?.status ?? 1}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 防止把已安装版本较新的项目降级,或把无法比较的版本误判为相同。 */
|
|
94
|
+
function versionBlockReason(project, desired) {
|
|
95
|
+
for (const key of ["flower", "trellis"]) {
|
|
96
|
+
const relation = compareVersions(project[key], desired[key]);
|
|
97
|
+
if (relation === 1) return `${key} 项目版本高于目标版本(${project[key]} > ${desired[key]})`;
|
|
98
|
+
if (relation === 0 && project[key] !== desired[key]) {
|
|
99
|
+
return `${key} 版本无法比较(${project[key]} / ${desired[key]})`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 校验现代 Plugin 状态后读取项目版本,避免旧 manifest 掩盖损坏状态。 */
|
|
106
|
+
function readValidatedProjectVersions(target) {
|
|
107
|
+
const store = new ProjectStore(target);
|
|
108
|
+
store.readLock();
|
|
109
|
+
store.readState();
|
|
110
|
+
return readProjectVersions(target);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 输出逐项目状态和计数;结果块供 AI 接续独立 Git 确认。 */
|
|
114
|
+
function printResult(rows, options, log) {
|
|
115
|
+
const labels = {
|
|
116
|
+
planned: "待更新",
|
|
117
|
+
updated: "已更新",
|
|
118
|
+
up_to_date: "已是最新版",
|
|
119
|
+
skipped: "跳过",
|
|
120
|
+
failed: "失败",
|
|
121
|
+
};
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
const prefix = row.status === "updated" ? " ✓ " : " · ";
|
|
124
|
+
log(`${prefix}${labels[row.status]}: ${row.target}${row.reason ? ` (${row.reason})` : ""}`);
|
|
125
|
+
}
|
|
126
|
+
const counts = Object.fromEntries(Object.keys(labels).map((status) => [status, rows.filter((row) => row.status === status).length]));
|
|
127
|
+
log(` · 汇总: 待更新 ${counts.planned},已更新 ${counts.updated},已是最新版 ${counts.up_to_date},跳过 ${counts.skipped},失败 ${counts.failed}`);
|
|
128
|
+
if (options.dryRun) {
|
|
129
|
+
log(" · 写入:否");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const attempted = rows.filter((row) => row.attempted);
|
|
133
|
+
if (attempted.length > 0) {
|
|
134
|
+
log("<flower-update-result>");
|
|
135
|
+
log(`status: ${counts.failed || counts.skipped ? "partial" : "completed"}`);
|
|
136
|
+
log(`updated_targets: ${JSON.stringify(rows.filter((row) => row.status === "updated").map((row) => row.target))}`);
|
|
137
|
+
log(`attempted_targets: ${JSON.stringify(attempted.map((row) => row.target))}`);
|
|
138
|
+
log("post_action: run_trellis_push_confirmation");
|
|
139
|
+
log("ai_instruction: 各项目按所属 Git 仓库分别加载并遵循 trellis-push,展示精确文件范围和提交信息,等待确认;不要自动提交或推送。");
|
|
140
|
+
log("</flower-update-result>");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 批量更新显式指定或在根目录发现的 Flower 项目。
|
|
146
|
+
* @param {object} ctx CLI 解析上下文
|
|
147
|
+
* @param {{fetchTags?:Function,install?:Function,inspectCli?:Function,updateProject?:Function,log?:Function}} [options] 测试替身
|
|
148
|
+
* @returns {Promise<number>} 0 表示全部完成,1 表示存在未完成项目
|
|
149
|
+
*/
|
|
150
|
+
export async function updateAll(ctx, options = {}) {
|
|
151
|
+
if (hasHelpFlag(ctx.passthrough)) {
|
|
152
|
+
printUpdateAllHelp();
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
const flags = ctx.passthrough;
|
|
156
|
+
const unknown = flags.filter((arg) => !["--dry-run", "--yes", "-y"].includes(arg));
|
|
157
|
+
if (unknown.length || ctx.forwarded?.length) {
|
|
158
|
+
throw new Error(`update-all 不支持参数: ${[...unknown, ...(ctx.forwarded || [])].join(" ")}`);
|
|
159
|
+
}
|
|
160
|
+
const dryRun = flags.includes("--dry-run");
|
|
161
|
+
if (!dryRun && !flags.includes("--yes") && !flags.includes("-y")) {
|
|
162
|
+
throw new Error("update-all 执行写入前需要 --yes;如需预览请使用 --dry-run");
|
|
163
|
+
}
|
|
164
|
+
if (!ctx.roots.length && !ctx.targets.length) {
|
|
165
|
+
throw new Error("update-all 至少需要一个 --root 或 --target");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const log = options.log || console.log;
|
|
169
|
+
const { projects, errors } = discoverFlowerProjects(ctx.roots, ctx.targets);
|
|
170
|
+
const rows = errors.map(({ target, reason }) => ({ target, status: "failed", reason }));
|
|
171
|
+
if (!projects.length && !errors.length) {
|
|
172
|
+
rows.push({ target: ctx.roots.join(", "), status: "failed", reason: "未发现已安装 Flower 的项目" });
|
|
173
|
+
}
|
|
174
|
+
const gitChecks = new Map();
|
|
175
|
+
for (const target of projects) {
|
|
176
|
+
const gitRoot = gitRepositoryRoot(target);
|
|
177
|
+
if (!gitRoot) {
|
|
178
|
+
rows.push({ target, status: "skipped", reason: "不是 Git 工作区" });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!gitChecks.has(gitRoot)) gitChecks.set(gitRoot, gitSafety(gitRoot));
|
|
182
|
+
const git = gitChecks.get(gitRoot);
|
|
183
|
+
if (!git.clean) {
|
|
184
|
+
rows.push({ target, status: "skipped", reason: git.reason === "dirty_worktree" ? `Git 工作区有 ${git.dirtyCount} 项变动` : `Git 检查失败(${git.reason})` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const taskSafety = projectTaskSafety(target);
|
|
188
|
+
if (!taskSafety.safe) {
|
|
189
|
+
rows.push({ target, status: "skipped", reason: taskSafety.reason });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const version = readValidatedProjectVersions(target);
|
|
194
|
+
if (!version.flower || !version.trellis) {
|
|
195
|
+
rows.push({ target, status: "skipped", reason: "Flower/Trellis 项目版本证据缺失" });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
rows.push({ target, status: "candidate", version });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
rows.push({ target, status: "skipped", reason: `读取项目版本失败(${error.message})` });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const local = { flower: flowerVersion(), trellis: trellisVersion() };
|
|
205
|
+
let tags = null;
|
|
206
|
+
if (projects.length && ctx.updateCheck !== false && !process.env.FLOWER_NO_UPDATE_CHECK) {
|
|
207
|
+
try {
|
|
208
|
+
tags = await (options.fetchTags || fetchPackageDistTags)();
|
|
209
|
+
} catch {
|
|
210
|
+
tags = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const recommendation = getUpdateRecommendation(local.flower, tags);
|
|
214
|
+
const desiredFlower = recommendation?.version || local.flower;
|
|
215
|
+
log(`update-all ${dryRun ? "dry-run" : "执行"}:`);
|
|
216
|
+
log(` · 远端版本: ${tags ? `已确认(${recommendation ? recommendation.version : "无更新"})` : "未确认,仅使用本机版本"}`);
|
|
217
|
+
log(` · 目标 Flower: ${desiredFlower}`);
|
|
218
|
+
log(` · 目标 Trellis: ${recommendation ? "安装新版 CLI 后核对" : local.trellis}`);
|
|
219
|
+
if (recommendation) log(` · 全局升级: ${recommendation.command}`);
|
|
220
|
+
|
|
221
|
+
for (const row of rows) {
|
|
222
|
+
if (row.status !== "candidate") continue;
|
|
223
|
+
const reason = versionBlockReason(row.version, { flower: desiredFlower, trellis: recommendation ? row.version.trellis : local.trellis });
|
|
224
|
+
if (reason) {
|
|
225
|
+
row.status = "skipped";
|
|
226
|
+
row.reason = reason;
|
|
227
|
+
} else if (recommendation || row.version.flower !== local.flower || row.version.trellis !== local.trellis) {
|
|
228
|
+
row.status = "planned";
|
|
229
|
+
} else {
|
|
230
|
+
row.status = "up_to_date";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!dryRun && rows.some((row) => row.status === "planned")) {
|
|
235
|
+
let installed = local;
|
|
236
|
+
const firstPlanned = rows.find((row) => row.status === "planned").target;
|
|
237
|
+
try {
|
|
238
|
+
if (recommendation) {
|
|
239
|
+
log(" · 正在安装 Flower 新版本");
|
|
240
|
+
const result = (options.install || installFlowerVersion)(recommendation.version, { cwd: firstPlanned });
|
|
241
|
+
if (result?.status !== 0) throw new Error(`全局安装失败(${processFailure(result)})`);
|
|
242
|
+
}
|
|
243
|
+
installed = (options.inspectCli || inspectInstalledCli)();
|
|
244
|
+
if (installed.flower !== desiredFlower || !installed.trellis || installed.trellis === "(未安装)") {
|
|
245
|
+
throw new Error(`已安装 CLI 版本不符(Flower ${installed.flower || "unknown"},Trellis ${installed.trellis || "unknown"})`);
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
for (const row of rows) {
|
|
249
|
+
if (row.status === "planned") {
|
|
250
|
+
row.status = "failed";
|
|
251
|
+
row.reason = error.message;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
printResult(rows, { dryRun }, log);
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const row of rows) {
|
|
259
|
+
if (row.status !== "planned") continue;
|
|
260
|
+
const reason = versionBlockReason(row.version, installed);
|
|
261
|
+
if (reason) {
|
|
262
|
+
row.status = "skipped";
|
|
263
|
+
row.reason = reason;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (row.version.flower === installed.flower && row.version.trellis === installed.trellis) {
|
|
267
|
+
row.status = "up_to_date";
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
log(` · 正在更新: ${row.target}`);
|
|
271
|
+
row.attempted = true;
|
|
272
|
+
try {
|
|
273
|
+
const result = (options.updateProject || runProjectUpdate)(row.target, installed);
|
|
274
|
+
if (result?.status !== 0) throw new Error(processFailure(result));
|
|
275
|
+
const actual = readValidatedProjectVersions(row.target);
|
|
276
|
+
if (actual.flower !== installed.flower || actual.trellis !== installed.trellis) {
|
|
277
|
+
throw new Error(`更新后版本未追平(Flower ${actual.flower || "unknown"},Trellis ${actual.trellis || "unknown"})`);
|
|
278
|
+
}
|
|
279
|
+
row.status = "updated";
|
|
280
|
+
} catch (error) {
|
|
281
|
+
row.status = "failed";
|
|
282
|
+
row.reason = `项目更新失败(${error.message})`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
printResult(rows, { dryRun }, log);
|
|
288
|
+
return rows.some((row) => row.status === "failed" || row.status === "skipped") || projects.length === 0 ? 1 : 0;
|
|
289
|
+
}
|
package/src/constants.js
CHANGED
package/src/lib/cli-args.js
CHANGED
|
@@ -25,6 +25,8 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
|
|
|
25
25
|
let variant = null;
|
|
26
26
|
let target = cwd;
|
|
27
27
|
let targetExplicit = false;
|
|
28
|
+
const targets = [];
|
|
29
|
+
const roots = [];
|
|
28
30
|
let updateCheck = true;
|
|
29
31
|
let backupRetention = DEFAULT_UPDATE_BACKUP_RETENTION;
|
|
30
32
|
const skills = [];
|
|
@@ -60,10 +62,26 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
|
|
|
60
62
|
case "--variant":
|
|
61
63
|
variant = argv[++i] || null;
|
|
62
64
|
break;
|
|
63
|
-
case "--target":
|
|
65
|
+
case "--target": {
|
|
66
|
+
const value = argv[i + 1];
|
|
67
|
+
if (command === "update-all" && (!value || value.startsWith("-"))) {
|
|
68
|
+
targets.push(null);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
64
71
|
target = path.resolve(cwd, argv[++i] || ".");
|
|
65
72
|
targetExplicit = true;
|
|
73
|
+
if (command === "update-all") targets.push(target);
|
|
66
74
|
break;
|
|
75
|
+
}
|
|
76
|
+
case "--root": {
|
|
77
|
+
if (command !== "update-all") {
|
|
78
|
+
passthrough.push(a);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const value = argv[i + 1];
|
|
82
|
+
roots.push(!value || value.startsWith("-") ? null : path.resolve(cwd, argv[++i]));
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
67
85
|
case "--no-update-check":
|
|
68
86
|
updateCheck = false;
|
|
69
87
|
break;
|
|
@@ -88,6 +106,8 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
|
|
|
88
106
|
ctx: {
|
|
89
107
|
target: path.resolve(target),
|
|
90
108
|
targetExplicit,
|
|
109
|
+
targets,
|
|
110
|
+
roots,
|
|
91
111
|
passthrough,
|
|
92
112
|
enhance,
|
|
93
113
|
enhanceOnly,
|
package/src/lib/self-check.js
CHANGED
|
@@ -124,11 +124,16 @@ async function fetchMissingReleaseNotes(target, projectEvidence, writeCache, ran
|
|
|
124
124
|
return releaseNotes;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
127
|
+
/**
|
|
128
|
+
* 检查目标目录 Git 工作区是否干净。
|
|
129
|
+
* @param {string} target 目标目录
|
|
130
|
+
* @returns {{clean:boolean,reason:string|null,dirtyCount?:number}} 检查结果
|
|
131
|
+
*/
|
|
132
|
+
export function gitSafety(target) {
|
|
129
133
|
const common = {
|
|
130
134
|
cwd: target,
|
|
131
135
|
encoding: "utf8",
|
|
136
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
132
137
|
shell: process.platform === "win32",
|
|
133
138
|
stdio: ["ignore", "pipe", "pipe"],
|
|
134
139
|
timeout: 1500,
|
|
@@ -147,8 +152,12 @@ function gitSafety(target) {
|
|
|
147
152
|
: { clean: true, reason: null, dirtyCount: 0 };
|
|
148
153
|
}
|
|
149
154
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
155
|
+
/**
|
|
156
|
+
* 查找 active 或 in_progress 的 Trellis 任务。
|
|
157
|
+
* @param {string} target 项目根目录
|
|
158
|
+
* @returns {boolean} 是否存在活动任务
|
|
159
|
+
*/
|
|
160
|
+
export function hasActiveTask(target) {
|
|
152
161
|
const tasksDir = path.join(target, ".trellis", "tasks");
|
|
153
162
|
const stack = [tasksDir];
|
|
154
163
|
while (stack.length) {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const SKIP_DIRECTORIES = new Set([".git", ".trellis", ".flower", "node_modules"]);
|
|
6
|
+
const INACTIVE_TASK_STATUSES = new Set(["planning", "completed", "done"]);
|
|
7
|
+
|
|
8
|
+
/** 判断目录是否已有 Flower 安装记录。 */
|
|
9
|
+
function isFlowerProject(directory) {
|
|
10
|
+
const trellis = path.join(directory, ".trellis");
|
|
11
|
+
const hasLock = fs.existsSync(path.join(directory, ".flower", "plugin-lock.json"));
|
|
12
|
+
const hasManifest = fs.existsSync(path.join(trellis, ".flower-manifest.json"));
|
|
13
|
+
if (!hasLock && !hasManifest) return false;
|
|
14
|
+
try {
|
|
15
|
+
const stat = fs.lstatSync(trellis);
|
|
16
|
+
if (stat.isSymbolicLink()) throw new Error(".trellis 是软链接");
|
|
17
|
+
return stat.isDirectory();
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === "ENOENT") return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 把用户输入解析为真实目录,避免重复路径与符号链接循环。 */
|
|
25
|
+
function resolveDirectory(input) {
|
|
26
|
+
if (!input) return { directory: null, reason: "缺少目录路径" };
|
|
27
|
+
try {
|
|
28
|
+
const directory = fs.realpathSync(path.resolve(input));
|
|
29
|
+
if (!fs.statSync(directory).isDirectory()) {
|
|
30
|
+
return { directory: null, reason: "目标不是目录" };
|
|
31
|
+
}
|
|
32
|
+
return { directory, reason: null };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { directory: null, reason: `无法读取目录(${error.code || error.message})` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 在显式根目录与目标路径中发现 Flower 项目。
|
|
40
|
+
* @param {string[]} roots 允许扫描的父目录
|
|
41
|
+
* @param {string[]} targets 显式项目目录
|
|
42
|
+
* @returns {{projects:string[],errors:Array<{target:string,reason:string}>}} 去重项目与输入错误
|
|
43
|
+
*/
|
|
44
|
+
export function discoverFlowerProjects(roots, targets) {
|
|
45
|
+
const projects = new Set();
|
|
46
|
+
const errors = [];
|
|
47
|
+
|
|
48
|
+
for (const input of targets) {
|
|
49
|
+
const { directory, reason } = resolveDirectory(input);
|
|
50
|
+
if (!directory) {
|
|
51
|
+
errors.push({ target: input || "--target", reason });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (isFlowerProject(directory)) projects.add(directory);
|
|
56
|
+
else errors.push({ target: input, reason: "目标不是已安装 Flower 项目" });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
errors.push({ target: input, reason: `无法读取安装记录(${error.code || error.message})` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const input of roots) {
|
|
63
|
+
const { directory, reason } = resolveDirectory(input);
|
|
64
|
+
if (!directory) {
|
|
65
|
+
errors.push({ target: input || "--root", reason });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const stack = [directory];
|
|
69
|
+
while (stack.length) {
|
|
70
|
+
const current = stack.pop();
|
|
71
|
+
try {
|
|
72
|
+
if (isFlowerProject(current)) projects.add(current);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
errors.push({ target: current, reason: `无法读取安装记录(${error.code || error.message})` });
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const entries = fs.readdirSync(current, { withFileTypes: true })
|
|
78
|
+
.filter((entry) => entry.isDirectory() && !SKIP_DIRECTORIES.has(entry.name))
|
|
79
|
+
.sort((a, b) => b.name.localeCompare(a.name));
|
|
80
|
+
for (const entry of entries) stack.push(path.join(current, entry.name));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
errors.push({ target: current, reason: `无法扫描目录(${error.code || error.message})` });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { projects: [...projects].sort(), errors };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 读取项目所属 Git 根,用于共享写入前的安全检查。
|
|
92
|
+
* @param {string} target Flower 项目目录
|
|
93
|
+
* @returns {string|null} Git 根真实路径;无法读取时返回 null
|
|
94
|
+
*/
|
|
95
|
+
export function gitRepositoryRoot(target) {
|
|
96
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
97
|
+
cwd: target,
|
|
98
|
+
encoding: "utf8",
|
|
99
|
+
shell: process.platform === "win32",
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
101
|
+
timeout: 1500,
|
|
102
|
+
});
|
|
103
|
+
if (result.error || result.status !== 0) return null;
|
|
104
|
+
try {
|
|
105
|
+
return fs.realpathSync(String(result.stdout || "").trim());
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 严格检查批量更新目标的任务状态;不可读状态不能等同于没有活动任务。
|
|
113
|
+
* @param {string} target Flower 项目目录
|
|
114
|
+
* @returns {{safe:boolean,reason:string|null}} 是否允许进入更新队列
|
|
115
|
+
*/
|
|
116
|
+
export function projectTaskSafety(target) {
|
|
117
|
+
const tasksDir = path.join(target, ".trellis", "tasks");
|
|
118
|
+
let rootStat;
|
|
119
|
+
try {
|
|
120
|
+
rootStat = fs.lstatSync(tasksDir);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return error.code === "ENOENT"
|
|
123
|
+
? { safe: true, reason: null }
|
|
124
|
+
: { safe: false, reason: `无法读取任务目录(${error.code || error.message})` };
|
|
125
|
+
}
|
|
126
|
+
if (!rootStat.isDirectory()) return { safe: false, reason: "任务目录不是普通目录" };
|
|
127
|
+
|
|
128
|
+
const stack = [tasksDir];
|
|
129
|
+
while (stack.length) {
|
|
130
|
+
const directory = stack.pop();
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { safe: false, reason: `无法读取任务目录(${error.code || error.message})` };
|
|
136
|
+
}
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const absolute = path.join(directory, entry.name);
|
|
139
|
+
if (entry.isSymbolicLink()) return { safe: false, reason: `任务目录含软链接:${path.relative(target, absolute)}` };
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
stack.push(absolute);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (entry.name !== "task.json") continue;
|
|
145
|
+
if (!entry.isFile()) return { safe: false, reason: `任务记录不是普通文件:${path.relative(target, absolute)}` };
|
|
146
|
+
try {
|
|
147
|
+
const data = JSON.parse(fs.readFileSync(absolute, "utf8"));
|
|
148
|
+
if (data?.status === "in_progress" || data?.status === "active") {
|
|
149
|
+
return { safe: false, reason: "存在活动 Trellis 任务" };
|
|
150
|
+
}
|
|
151
|
+
if (!INACTIVE_TASK_STATUSES.has(data?.status)) {
|
|
152
|
+
return { safe: false, reason: `任务记录状态无法确认:${path.relative(target, absolute)}` };
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return { safe: false, reason: `任务记录损坏或不可读:${path.relative(target, absolute)} (${error.code || error.message})` };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { safe: true, reason: null };
|
|
160
|
+
}
|