flower-trellis 0.4.0 → 0.4.2
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/0.6/overrides/skills/trellis-finish-work.md +1 -3
- package/enhancements/0.6/overrides/workflow-states/in_progress-inline.md +5 -2
- package/enhancements/0.6/overrides/workflow-states/in_progress.md +4 -1
- package/enhancements/0.6/overrides/workflow-states/no_task.md +1 -1
- package/enhancements/0.6/overrides/workflow-states/planning-inline.md +1 -1
- package/enhancements/0.6/overrides/workflow-states/planning.md +1 -1
- package/enhancements/0.6/overrides/workflow.md +21 -15
- package/enhancements/MANIFEST.json +2 -2
- 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
|
@@ -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
|
+
}
|