flower-trellis 0.6.8 → 0.6.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 +24 -1
- package/enhancements/0.6/.agents/skills/trellis-route/scripts/route_state.py +2 -1
- package/enhancements/0.6/.claude/skills/trellis-route/scripts/route_state.py +2 -1
- package/enhancements/0.6/overrides/bundles/shared-hook-runtime.json +1 -1
- package/enhancements/0.6/overrides/patches/scripts/windows-streams/common-content.py +10 -0
- package/enhancements/0.6/overrides/patches/scripts/windows-streams/common-selector.py +14 -0
- package/enhancements/0.6/overrides/patches/scripts/windows-streams/hook-content.py +7 -0
- package/enhancements/0.6/overrides/patches/scripts/windows-streams/hook-selector.py +6 -0
- package/enhancements/0.6/overrides/patches/scripts/windows-streams/patch.json +186 -0
- package/enhancements/0.6/overrides/patches/scripts/windows-streams/prefix-content.py +1 -0
- package/enhancements/0.6/overrides/patches/scripts/windows-streams/prefix-selector.py +1 -0
- package/enhancements/0.6/scripts/auto_loop.py +21 -10
- package/enhancements/0.6/scripts/decision_log.py +7 -5
- package/enhancements/0.6/scripts/git_evidence.py +2 -1
- package/enhancements/0.6/scripts/pre_check_state.py +1 -1
- package/enhancements/0.6/scripts/task_intent.py +4 -0
- package/enhancements/0.6/scripts/worktree_setup.py +8 -3
- package/enhancements/MANIFEST.json +9 -2
- package/package.json +4 -4
- package/src/assets/flower_session_start.py +40 -15
- package/src/assets/flower_telemetry_hook.py +18 -3
- package/src/cli.js +7 -1
- package/src/commands/self-check.js +3 -2
- package/src/commands/self-update.js +10 -6
- package/src/commands/update.js +33 -19
- package/src/lib/global-trellis-sync.js +60 -8
- package/src/lib/operation-timing.js +50 -0
- package/src/lib/self-check.js +14 -54
- package/src/lib/terminal-state.js +5 -5
- package/src/lib/trellis-runner.js +34 -10
- package/src/lib/trellis-update-entry.js +10 -0
- package/src/lib/trellis-update-fetch.js +18 -0
- package/src/lib/update-check-cache.js +47 -0
- package/src/lib/update-check.js +125 -56
|
@@ -8,10 +8,9 @@ import argparse
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
+
import subprocess
|
|
11
12
|
import sys
|
|
12
|
-
from contextlib import redirect_stdout
|
|
13
13
|
from importlib.util import module_from_spec, spec_from_file_location
|
|
14
|
-
from io import StringIO
|
|
15
14
|
from pathlib import Path
|
|
16
15
|
|
|
17
16
|
|
|
@@ -86,6 +85,40 @@ def _load_hook(root: Path, hook: str):
|
|
|
86
85
|
return module
|
|
87
86
|
|
|
88
87
|
|
|
88
|
+
def _run_native_hook(root: Path, hook: str, hook_input: dict) -> dict | None:
|
|
89
|
+
"""在独立解释器中执行原生 hook,保留真实标准流边界。
|
|
90
|
+
|
|
91
|
+
@param root: 当前部署项目根目录。
|
|
92
|
+
@param hook: 已验证的原生平台 hook 相对路径。
|
|
93
|
+
@param hook_input: 宿主传入并已规范化 cwd 的事件 JSON。
|
|
94
|
+
@return: 原生标准 SessionStart 输出;原生 hook 跳过时返回 None。
|
|
95
|
+
"""
|
|
96
|
+
environment = os.environ.copy()
|
|
97
|
+
environment["PYTHONIOENCODING"] = "utf-8"
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
[sys.executable, "-X", "utf8", str(root / hook)],
|
|
100
|
+
cwd=root,
|
|
101
|
+
env=environment,
|
|
102
|
+
input=json.dumps(hook_input, ensure_ascii=False).encode("utf-8"),
|
|
103
|
+
capture_output=True,
|
|
104
|
+
check=False,
|
|
105
|
+
)
|
|
106
|
+
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
107
|
+
if stderr:
|
|
108
|
+
# 原生诊断属于真实 Hook 证据,不能因包装器捕获输出而被吞掉。
|
|
109
|
+
sys.stderr.write(stderr)
|
|
110
|
+
sys.stderr.flush()
|
|
111
|
+
if result.returncode != 0:
|
|
112
|
+
raise ValueError(f"原生 hook 退出码 {result.returncode}")
|
|
113
|
+
stdout = result.stdout.decode("utf-8-sig")
|
|
114
|
+
if not stdout.strip():
|
|
115
|
+
return None
|
|
116
|
+
output = json.loads(stdout)
|
|
117
|
+
if not isinstance(output, dict):
|
|
118
|
+
raise ValueError("原生 hook 输出必须为 JSON 对象")
|
|
119
|
+
return output
|
|
120
|
+
|
|
121
|
+
|
|
89
122
|
def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | None:
|
|
90
123
|
"""生成指定分段,只有 state 执行原生主入口的副作用。
|
|
91
124
|
|
|
@@ -97,21 +130,10 @@ def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | No
|
|
|
97
130
|
"""
|
|
98
131
|
if hook not in HOOKS or part not in PARTS:
|
|
99
132
|
raise ValueError("不支持的 SessionStart hook 或分段")
|
|
100
|
-
module = _load_hook(root, hook)
|
|
101
|
-
if module.should_skip_injection():
|
|
102
|
-
return None
|
|
103
133
|
if part == "state":
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
try:
|
|
107
|
-
sys.stdin = StringIO(json.dumps(hook_input, ensure_ascii=False))
|
|
108
|
-
with redirect_stdout(output):
|
|
109
|
-
module.main()
|
|
110
|
-
finally:
|
|
111
|
-
sys.stdin = original_stdin
|
|
112
|
-
if not output.getvalue().strip():
|
|
134
|
+
result = _run_native_hook(root, hook, hook_input)
|
|
135
|
+
if result is None:
|
|
113
136
|
return None
|
|
114
|
-
result = json.loads(output.getvalue())
|
|
115
137
|
context = result["hookSpecificOutput"]["additionalContext"]
|
|
116
138
|
if len(WORKFLOW_BLOCK.findall(context)) != 1:
|
|
117
139
|
raise ValueError("原生启动输出必须包含且仅包含一个 trellis-workflow 块")
|
|
@@ -133,6 +155,9 @@ def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | No
|
|
|
133
155
|
result["systemMessage"] = "\n".join(filter(None, [result.get("systemMessage"), message]))
|
|
134
156
|
print(message, file=sys.stderr)
|
|
135
157
|
else:
|
|
158
|
+
module = _load_hook(root, hook)
|
|
159
|
+
if module.should_skip_injection():
|
|
160
|
+
return None
|
|
136
161
|
builder = module._build_workflow_toc if hook == HOOKS[0] else module._build_workflow_overview
|
|
137
162
|
context = split_workflow(builder(root / ".trellis/workflow.md"))[part]
|
|
138
163
|
result = {"hookSpecificOutput": {"hookEventName": "SessionStart"}}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
+
import ntpath
|
|
8
9
|
import os
|
|
9
10
|
import shutil
|
|
10
11
|
import subprocess
|
|
@@ -57,7 +58,7 @@ def _can_skip(platform: str) -> bool:
|
|
|
57
58
|
return hint.get("delivered") is True
|
|
58
59
|
retry = meta.get("nextRetryAt")
|
|
59
60
|
return bool(retry and datetime.fromisoformat(retry.replace("Z", "+00:00")).timestamp() > time.time())
|
|
60
|
-
except (OSError, ValueError, TypeError, AttributeError):
|
|
61
|
+
except (OSError, ValueError, TypeError, AttributeError, RuntimeError):
|
|
61
62
|
return False
|
|
62
63
|
|
|
63
64
|
|
|
@@ -86,10 +87,24 @@ def main() -> None:
|
|
|
86
87
|
command = shutil.which("flower-trellis")
|
|
87
88
|
if not command:
|
|
88
89
|
return
|
|
90
|
+
argv = [command, "telemetry", "record-activity", platform, "--target", target]
|
|
91
|
+
run_options = {}
|
|
92
|
+
if os.name == "nt" and os.path.splitext(command)[1].lower() in {".cmd", ".bat"}:
|
|
93
|
+
processor = os.environ.get("COMSPEC")
|
|
94
|
+
if not processor and os.environ.get("SystemRoot"):
|
|
95
|
+
processor = os.path.join(os.environ["SystemRoot"], "System32", "cmd.exe")
|
|
96
|
+
if not processor or not ntpath.isabs(processor):
|
|
97
|
+
return
|
|
98
|
+
# CMD 的环境占位符只展开一轮;引号和关闭延迟展开共同保留特殊路径字符。
|
|
99
|
+
environment = os.environ.copy()
|
|
100
|
+
environment["FLOWER_ACTIVITY_HOOK_CLI"] = command
|
|
101
|
+
environment["FLOWER_ACTIVITY_HOOK_TARGET"] = target
|
|
102
|
+
argv = f'"{processor}" /d /v:off /s /c ""%FLOWER_ACTIVITY_HOOK_CLI%" telemetry record-activity {platform} --target "%FLOWER_ACTIVITY_HOOK_TARGET%""'
|
|
103
|
+
run_options = {"executable": processor, "env": environment}
|
|
89
104
|
# 只转交固定事件类型、平台和本地定位参数;Node 独占身份创建及队列写入。
|
|
90
|
-
subprocess.run(
|
|
105
|
+
subprocess.run(argv,
|
|
91
106
|
cwd=target, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
92
|
-
stderr=subprocess.DEVNULL, timeout=3, check=False)
|
|
107
|
+
stderr=subprocess.DEVNULL, timeout=3, check=False, **run_options)
|
|
93
108
|
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
|
|
94
109
|
return
|
|
95
110
|
|
package/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { readManifest } from "./lib/manifest.js";
|
|
|
7
7
|
import { ProjectStore } from "./plugin/state/project-store.js";
|
|
8
8
|
import { runTrellis } from "./lib/trellis-runner.js";
|
|
9
9
|
import { parseCliArgs } from "./lib/cli-args.js";
|
|
10
|
-
import { installWindowsTerminalInputRecovery } from "./lib/terminal-state.js";
|
|
10
|
+
import { installWindowsTerminalInputRecovery, scheduleWindowsTerminalExit } from "./lib/terminal-state.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* flower-trellis CLI 主入口。
|
|
@@ -204,6 +204,12 @@ async function main() {
|
|
|
204
204
|
const code = await runTrellis([cmd, ...ctx.passthrough], ctx.target);
|
|
205
205
|
process.exit(code);
|
|
206
206
|
}
|
|
207
|
+
// 非交互完成不会经过退出菜单;必须等编排、计时和遥测收尾后再释放 Windows CLI。
|
|
208
|
+
if (process.platform === "win32" && (cmd === "init" || cmd === "update")) {
|
|
209
|
+
// Windows 管道写入是异步的,显式退出前等两个输出流排空,避免截断完成行和计时。
|
|
210
|
+
await Promise.all([process.stdout, process.stderr].map((stream) => new Promise((resolve) => stream.write("", resolve))));
|
|
211
|
+
scheduleWindowsTerminalExit({ exitCode: process.exitCode ?? 0 });
|
|
212
|
+
}
|
|
207
213
|
} catch (err) {
|
|
208
214
|
console.error(`❌ ${err.message}`);
|
|
209
215
|
if (process.env.DEBUG || process.env.FLOWER_DEBUG) console.error(err.stack);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timeOperation } from "../lib/operation-timing.js";
|
|
1
2
|
import { buildSelfCheck } from "../lib/self-check.js";
|
|
2
3
|
import { reportTelemetry } from "../lib/telemetry.js";
|
|
3
4
|
import { hasHelpFlag } from "../lib/cli-args.js";
|
|
@@ -39,11 +40,11 @@ export async function selfCheck(ctx) {
|
|
|
39
40
|
if (ctx.updateCheck === false) {
|
|
40
41
|
process.env.FLOWER_NO_UPDATE_CHECK = "1";
|
|
41
42
|
}
|
|
42
|
-
const result = await buildSelfCheck(ctx.target, {
|
|
43
|
+
const result = await timeOperation("self-check", "预检", () => buildSelfCheck(ctx.target, {
|
|
43
44
|
forceRemote,
|
|
44
45
|
ignorePromptSuppression: manual,
|
|
45
46
|
recordPrompt: !manual,
|
|
46
47
|
onRemoteCheck: () => reportTelemetry(ctx.target, "version_check"),
|
|
47
|
-
});
|
|
48
|
+
}));
|
|
48
49
|
console.log(JSON.stringify(result, null, 2));
|
|
49
50
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timeOperation } from "../lib/operation-timing.js";
|
|
1
2
|
import { observeTelemetryOperation, beginTelemetryOperation, completeTelemetryOperation } from "../lib/telemetry-operation.js";
|
|
2
3
|
import { spawnSync } from "node:child_process";
|
|
3
4
|
import {
|
|
@@ -129,7 +130,8 @@ function printFlowerUpdateResult(fields) {
|
|
|
129
130
|
* @returns {Promise<void>}
|
|
130
131
|
*/
|
|
131
132
|
export async function selfUpdate(ctx) {
|
|
132
|
-
|
|
133
|
+
if (hasHelpFlag(ctx.passthrough)) return executeSelfUpdate(ctx);
|
|
134
|
+
return timeOperation("self-update", "流程总计(包含子阶段及交互等待)", () => observeTelemetryOperation(ctx, "self_update", executeSelfUpdate));
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
/** 执行已建立外部操作上下文的命令。
|
|
@@ -152,11 +154,11 @@ async function executeSelfUpdate(ctx) {
|
|
|
152
154
|
"--no-update-check",
|
|
153
155
|
...projectUpdateForwardArgs(forwarded),
|
|
154
156
|
];
|
|
155
|
-
const check = await buildSelfCheck(ctx.target, {
|
|
157
|
+
const check = await timeOperation("self-update", "预检", () => buildSelfCheck(ctx.target, {
|
|
156
158
|
writeCache: !dryRun,
|
|
157
159
|
forceRemote: !projectOnly,
|
|
158
160
|
ignorePromptSuppression: true,
|
|
159
|
-
});
|
|
161
|
+
}));
|
|
160
162
|
const shouldInstallFlower = !projectOnly && check.status === "update_available" && check.recommendation;
|
|
161
163
|
const effectiveSafety = check.safety || safetyState(
|
|
162
164
|
ctx.target,
|
|
@@ -207,7 +209,8 @@ async function executeSelfUpdate(ctx) {
|
|
|
207
209
|
|
|
208
210
|
beginTelemetryOperation(ctx);
|
|
209
211
|
if (shouldInstallFlower) {
|
|
210
|
-
|
|
212
|
+
console.log(" · 正在安装 Flower 新版本(含依赖与全局同步)");
|
|
213
|
+
const res = timeOperation("self-update", "全局安装(含依赖与postinstall)", () => installFlowerVersion(check.recommendation.version, { cwd: ctx.target }));
|
|
211
214
|
if (res.status !== 0) {
|
|
212
215
|
const reason = res.error ? res.error.message : `退出码 ${res.status ?? 1}`;
|
|
213
216
|
throw Object.assign(new Error(`全局 flower-trellis 升级失败(${reason})`), { code: res.status === 130 ? "FLOWER_OPERATION_CANCELLED" : "FLOWER_UPSTREAM_FAILED" });
|
|
@@ -216,13 +219,14 @@ async function executeSelfUpdate(ctx) {
|
|
|
216
219
|
console.log(" · 跳过全局 flower-trellis 升级");
|
|
217
220
|
}
|
|
218
221
|
|
|
219
|
-
|
|
222
|
+
console.log(" · 正在更新目标项目");
|
|
223
|
+
timeOperation("self-update", "项目更新(含子阶段及交互等待)", () => runCommand(
|
|
220
224
|
"flower-trellis",
|
|
221
225
|
projectArgs,
|
|
222
226
|
ctx.target,
|
|
223
227
|
"目标项目重叠加失败,请手动运行:" + projectUpdateCommand(ctx.target, forwarded),
|
|
224
228
|
{ ...process.env, FLOWER_TELEMETRY_PARENT_OPERATION: ctx.telemetryOperation?.id || "self_update" },
|
|
225
|
-
);
|
|
229
|
+
));
|
|
226
230
|
|
|
227
231
|
completeTelemetryOperation(ctx, "self_update");
|
|
228
232
|
const dirty = gitDirtySummary(ctx.target);
|
package/src/commands/update.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { beginOperationTiming, timeOperation } from "../lib/operation-timing.js";
|
|
1
2
|
import { observeTelemetryOperation, beginTelemetryOperation, completeTelemetryOperation } from "../lib/telemetry-operation.js";
|
|
2
3
|
import { runTrellis, runTrellisPty } from "../lib/trellis-runner.js";
|
|
3
4
|
import { hasHelpFlag, trellisUpdatePassthroughArgs } from "../lib/cli-args.js";
|
|
@@ -172,20 +173,20 @@ export async function replayPlugins(ctx, target, dryRun, compensationSnapshot =
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
async function previewCrossVersionUpdate(ctx, currentVersion, targetVersion) {
|
|
175
|
-
const sandbox = createUpdateSandbox(ctx.target);
|
|
176
|
+
const sandbox = timeOperation("update", "创建沙箱", () => createUpdateSandbox(ctx.target));
|
|
176
177
|
console.log(
|
|
177
178
|
`· 跨版本 dry-run:在项目外沙箱预演 Trellis + Plugin (${currentVersion} → ${targetVersion})`,
|
|
178
179
|
);
|
|
179
180
|
try {
|
|
180
|
-
const code = await runTrellis(
|
|
181
|
+
const code = await timeOperation("update", "沙箱上游更新", () => runTrellis(
|
|
181
182
|
["update", ...sandboxTrellisUpdateArgs(ctx.passthrough)],
|
|
182
183
|
sandbox.root,
|
|
183
|
-
{ stripBanner: true },
|
|
184
|
-
);
|
|
184
|
+
{ stripBanner: true, managedUpdate: true },
|
|
185
|
+
));
|
|
185
186
|
if (code !== 0) throw new Error(`沙箱 trellis update 失败(退出码 ${code})`);
|
|
186
|
-
await replayPlugins(ctx, sandbox.root, true);
|
|
187
|
+
await timeOperation("update", "沙箱插件预演", () => replayPlugins(ctx, sandbox.root, true));
|
|
187
188
|
} finally {
|
|
188
|
-
disposeUpdateSandbox(sandbox);
|
|
189
|
+
timeOperation("update", "清理沙箱", () => disposeUpdateSandbox(sandbox));
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
|
|
@@ -240,7 +241,18 @@ function printBackupRetentionResult(result, output = console) {
|
|
|
240
241
|
* @returns {Promise<void>} 升级、强化叠加与备份保留处理完成后返回
|
|
241
242
|
*/
|
|
242
243
|
export async function update(ctx) {
|
|
243
|
-
|
|
244
|
+
if (ctx.finishUpdateTiming || hasHelpFlag(ctx.passthrough)) {
|
|
245
|
+
return observeTelemetryOperation(ctx, "update", executeUpdate);
|
|
246
|
+
}
|
|
247
|
+
const finish = beginOperationTiming("update", "总计(包含子阶段)");
|
|
248
|
+
try {
|
|
249
|
+
return await observeTelemetryOperation({ ...ctx, finishUpdateTiming: finish }, "update", executeUpdate);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
finish(false);
|
|
252
|
+
throw error;
|
|
253
|
+
} finally {
|
|
254
|
+
finish();
|
|
255
|
+
}
|
|
244
256
|
}
|
|
245
257
|
|
|
246
258
|
/** 执行已建立外部操作上下文的命令。
|
|
@@ -262,6 +274,7 @@ async function executeUpdate(ctx) {
|
|
|
262
274
|
trellisControlExtendSnapshot: extendSnapshot,
|
|
263
275
|
}));
|
|
264
276
|
completeTelemetryOperation(ctx, "update");
|
|
277
|
+
ctx.finishUpdateTiming?.();
|
|
265
278
|
await showCommandCompletion("update", ctx.target, { passthrough: ctx.passthrough, outcome: "success", output: ctx.trellisControlQuiet ? SILENT_OUTPUT : console });
|
|
266
279
|
return result;
|
|
267
280
|
}
|
|
@@ -292,34 +305,34 @@ async function executeUpdate(ctx) {
|
|
|
292
305
|
await checkForUpdate(ctx, "update");
|
|
293
306
|
|
|
294
307
|
output.log("\n同步全局 Trellis:");
|
|
295
|
-
syncGlobalTrellis({ logger: output, stdio: quiet ? "ignore" : "inherit" });
|
|
308
|
+
timeOperation("update", "同步全局Trellis", () => syncGlobalTrellis({ logger: output, stdio: quiet ? "ignore" : "inherit" }));
|
|
296
309
|
|
|
297
310
|
try {
|
|
298
311
|
compensationSnapshot = !dryRun && !ctx.enhanceOnly
|
|
299
|
-
? createUpdateSnapshot(target)
|
|
312
|
+
? timeOperation("update", "创建恢复快照", () => createUpdateSnapshot(target))
|
|
300
313
|
: null;
|
|
301
314
|
if (useUpdateSandbox) {
|
|
302
315
|
await previewCrossVersionUpdate(ctx, currentTrellisVersion, targetTrellisVersion);
|
|
303
316
|
updateSucceeded = true;
|
|
304
317
|
} else if (!ctx.enhanceOnly) {
|
|
305
|
-
const code = await runTrellisPty(
|
|
318
|
+
const code = await timeOperation("update", "上游更新(含交互等待)", () => runTrellisPty(
|
|
306
319
|
["update", ...trellisUpdatePassthroughArgs(ctx.passthrough)],
|
|
307
320
|
target,
|
|
308
|
-
{ stripBanner: true, ...(quiet ? { stdout: SILENT_OUTPUT } : {}) },
|
|
309
|
-
);
|
|
321
|
+
{ stripBanner: true, managedUpdate: true, ...(quiet ? { stdout: SILENT_OUTPUT } : {}) },
|
|
322
|
+
));
|
|
310
323
|
if (code !== 0) {
|
|
311
324
|
throw Object.assign(new Error(`trellis update 失败(退出码 ${code}),已中止,未重新叠加`), { code: code === 130 ? "FLOWER_OPERATION_CANCELLED" : "FLOWER_UPSTREAM_FAILED" });
|
|
312
325
|
}
|
|
313
|
-
await replayPlugins(ctx, target, dryRun, compensationSnapshot);
|
|
326
|
+
await timeOperation("update", "插件重放", () => replayPlugins(ctx, target, dryRun, compensationSnapshot));
|
|
314
327
|
updateSucceeded = true;
|
|
315
328
|
} else {
|
|
316
329
|
output.log("· --enhance-only:跳过 trellis update,仅重新叠加强化包");
|
|
317
|
-
await replayPlugins(ctx, target, dryRun);
|
|
330
|
+
await timeOperation("update", "插件重放", () => replayPlugins(ctx, target, dryRun));
|
|
318
331
|
updateSucceeded = true;
|
|
319
332
|
}
|
|
320
333
|
} catch (error) {
|
|
321
334
|
if (compensationSnapshot) {
|
|
322
|
-
const recovery = restoreUpdateSnapshot(compensationSnapshot);
|
|
335
|
+
const recovery = timeOperation("update", "失败恢复", () => restoreUpdateSnapshot(compensationSnapshot));
|
|
323
336
|
if (!recovery.ok) {
|
|
324
337
|
output.error(` ✗ Update 补偿恢复不完整;快照保留:${recovery.manifestPath}`);
|
|
325
338
|
for (const failure of recovery.failedPaths) {
|
|
@@ -336,22 +349,22 @@ async function executeUpdate(ctx) {
|
|
|
336
349
|
throw error;
|
|
337
350
|
} finally {
|
|
338
351
|
if (updateSucceeded && !dryRun && !ctx.enhanceOnly) {
|
|
339
|
-
const restored = restoreConfigPreserveSnapshot(target, configSnapshot);
|
|
352
|
+
const restored = timeOperation("update", "恢复本地配置", () => restoreConfigPreserveSnapshot(target, configSnapshot));
|
|
340
353
|
if (restored.restored) {
|
|
341
354
|
output.log(` ✓ config.yaml 已保留本地配置: ${restored.keys.join(", ")}`);
|
|
342
355
|
}
|
|
343
356
|
}
|
|
344
357
|
if (compensationSnapshot && (updateSucceeded || compensationRecovered)) {
|
|
345
|
-
disposeUpdateSnapshot(compensationSnapshot);
|
|
358
|
+
timeOperation("update", "清理快照", () => disposeUpdateSnapshot(compensationSnapshot));
|
|
346
359
|
}
|
|
347
360
|
}
|
|
348
361
|
|
|
349
362
|
if (shouldManageBackups) {
|
|
350
|
-
const backupResult = pruneUpdateBackups(target, {
|
|
363
|
+
const backupResult = timeOperation("update", "清理旧备份", () => pruneUpdateBackups(target, {
|
|
351
364
|
retention: backupRetention,
|
|
352
365
|
beforeSnapshot: backupSnapshot,
|
|
353
366
|
dryRun,
|
|
354
|
-
});
|
|
367
|
+
}));
|
|
355
368
|
printBackupRetentionResult(backupResult, output);
|
|
356
369
|
} else if (!ctx.enhanceOnly && backupRetention === 0) {
|
|
357
370
|
output.log(" · --backup-retention 0:保留全部升级备份");
|
|
@@ -361,6 +374,7 @@ async function executeUpdate(ctx) {
|
|
|
361
374
|
const telemetryPromise = dryRun
|
|
362
375
|
? null
|
|
363
376
|
: reportTelemetry(target, "update_completed", { force: true });
|
|
377
|
+
if (!ctx.telemetryDeferCompletion) ctx.finishUpdateTiming?.();
|
|
364
378
|
if (!ctx.telemetryDeferCompletion) await showCommandCompletion("update", target, {
|
|
365
379
|
passthrough: ctx.passthrough,
|
|
366
380
|
outcome: dryRun ? "preview" : "success",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timeOperation } from "./operation-timing.js";
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
@@ -74,12 +75,13 @@ export function npmGlobalPrefix() {
|
|
|
74
75
|
* 计算 npm 全局 prefix 下的 trellis 可执行文件路径。
|
|
75
76
|
*
|
|
76
77
|
* @param {string|null} prefix npm 全局 prefix
|
|
78
|
+
* @param {string} [platform] 当前平台
|
|
77
79
|
* @returns {string|null} 可执行文件路径,不可推导或文件不存在时返回 null
|
|
78
80
|
*/
|
|
79
|
-
export function trellisBinInPrefix(prefix) {
|
|
81
|
+
export function trellisBinInPrefix(prefix, platform = process.platform) {
|
|
80
82
|
if (!prefix) return null;
|
|
81
83
|
const candidates =
|
|
82
|
-
|
|
84
|
+
platform === "win32"
|
|
83
85
|
? ["trellis.cmd", "trellis.ps1", "trellis"].map((name) => path.join(prefix, name))
|
|
84
86
|
: [path.join(prefix, "bin", "trellis")];
|
|
85
87
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
@@ -103,6 +105,48 @@ export function globalTrellisVersion(cwd = os.tmpdir(), command = "trellis") {
|
|
|
103
105
|
return extractLastVersion(`${res.stdout || ""}\n${res.stderr || ""}`);
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 从可验证的 npm 安装及启动器读取全局版本,无法证明归属时返回 null。
|
|
110
|
+
* @param {string|null} prefix npm 全局 prefix
|
|
111
|
+
* @param {string|null} command prefix 内实际启动器
|
|
112
|
+
* @param {string} [platform] 当前平台
|
|
113
|
+
* @returns {string|null} 实际安装版本或兼容探测信号
|
|
114
|
+
*/
|
|
115
|
+
export function installedGlobalTrellisVersion(prefix, command, platform = process.platform) {
|
|
116
|
+
if (!prefix || !command) return null;
|
|
117
|
+
try {
|
|
118
|
+
const root = path.join(prefix, ...(platform === "win32" ? [] : ["lib"]), "node_modules", TRELLIS_PACKAGE);
|
|
119
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
120
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.trellis;
|
|
121
|
+
if (pkg.name !== TRELLIS_PACKAGE || !VERSION_RE.test(pkg.version) || typeof bin !== "string") return null;
|
|
122
|
+
const target = path.resolve(root, bin);
|
|
123
|
+
const relative = path.relative(root, target);
|
|
124
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || !fs.statSync(target).isFile()) return null;
|
|
125
|
+
const realTarget = fs.realpathSync(target);
|
|
126
|
+
const realRelative = path.relative(fs.realpathSync(root), realTarget);
|
|
127
|
+
if (!realRelative || realRelative.startsWith("..") || path.isAbsolute(realRelative)) return null;
|
|
128
|
+
if (platform !== "win32") {
|
|
129
|
+
if (fs.realpathSync(command) !== realTarget) return null;
|
|
130
|
+
} else {
|
|
131
|
+
if (path.resolve(command) !== path.resolve(prefix, "trellis.cmd")) return null;
|
|
132
|
+
// 只接受 npm cmd-shim 的标准 Node 启动器全文;自定义包装可能另行调用其它版本。
|
|
133
|
+
const binRelative = path.relative(prefix, target).split(path.sep).join("\\");
|
|
134
|
+
const expected = [
|
|
135
|
+
"@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start",
|
|
136
|
+
"SETLOCAL", "CALL :find_dp0", "", 'IF EXIST "%dp0%\\node.exe" (',
|
|
137
|
+
' SET "_prog=%dp0%\\node.exe"', ') ELSE (', ' SET "_prog=node"',
|
|
138
|
+
' SET PATHEXT=%PATHEXT:;.JS;=;%', ')', '',
|
|
139
|
+
`endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${binRelative}" %*`,
|
|
140
|
+
"",
|
|
141
|
+
].join("\n");
|
|
142
|
+
if (fs.readFileSync(command, "utf8").replaceAll("\r\n", "\n") !== expected) return null;
|
|
143
|
+
}
|
|
144
|
+
return pkg.version;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
106
150
|
/**
|
|
107
151
|
* 同步本机全局 Trellis 到 flower-trellis 捆绑版本。
|
|
108
152
|
*
|
|
@@ -110,6 +154,10 @@ export function globalTrellisVersion(cwd = os.tmpdir(), command = "trellis") {
|
|
|
110
154
|
* @param {{log:(message:string)=>void}} [opts.logger] 输出对象,默认使用 console
|
|
111
155
|
* @param {string} [opts.cwd] 执行目录,默认当前目录
|
|
112
156
|
* @param {"inherit"|"ignore"} [opts.stdio] npm 安装子进程输出模式
|
|
157
|
+
* @param {string} [opts.prefix] 指定 npm prefix,用于隔离测试
|
|
158
|
+
* @param {string} [opts.platform] 平台测试替身
|
|
159
|
+
* @param {Function} [opts.spawn] npm 安装测试替身
|
|
160
|
+
* @param {Function} [opts.probeVersion] 兼容版本探测测试替身
|
|
113
161
|
* @returns {{targetVersion:string,currentVersion:string|null,installed:boolean,skipped:boolean,command:string}} 同步结果
|
|
114
162
|
*/
|
|
115
163
|
export function syncGlobalTrellis(opts = {}) {
|
|
@@ -129,9 +177,13 @@ export function syncGlobalTrellis(opts = {}) {
|
|
|
129
177
|
};
|
|
130
178
|
}
|
|
131
179
|
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
180
|
+
const platform = opts.platform || process.platform;
|
|
181
|
+
const prefix = timeOperation("全局Trellis", "定位安装", () => opts.prefix ?? npmGlobalPrefix());
|
|
182
|
+
const prefixBin = trellisBinInPrefix(prefix, platform);
|
|
183
|
+
const currentVersion = timeOperation("全局Trellis", "读取版本", () => (
|
|
184
|
+
installedGlobalTrellisVersion(prefix, prefixBin, platform) ||
|
|
185
|
+
(prefixBin ? (opts.probeVersion || globalTrellisVersion)(os.tmpdir(), prefixBin) : null)
|
|
186
|
+
));
|
|
135
187
|
if (currentVersion === targetVersion) {
|
|
136
188
|
logger.log(` ✓ 全局 Trellis 已是 ${targetVersion}`);
|
|
137
189
|
return {
|
|
@@ -149,11 +201,11 @@ export function syncGlobalTrellis(opts = {}) {
|
|
|
149
201
|
logger.log(` · 安装全局 Trellis:${targetVersion}`);
|
|
150
202
|
}
|
|
151
203
|
|
|
152
|
-
const res = spawnSync("npm", ["install", "-g", `${TRELLIS_PACKAGE}@${targetVersion}`], {
|
|
204
|
+
const res = timeOperation("全局Trellis", "安装", () => (opts.spawn || spawnSync)("npm", ["install", "-g", `${TRELLIS_PACKAGE}@${targetVersion}`], {
|
|
153
205
|
cwd,
|
|
154
206
|
stdio: opts.stdio || "inherit",
|
|
155
|
-
shell:
|
|
156
|
-
});
|
|
207
|
+
shell: platform === "win32",
|
|
208
|
+
}));
|
|
157
209
|
|
|
158
210
|
if (res.status !== 0) {
|
|
159
211
|
const reason = res.error ? res.error.message : `退出码 ${res.status ?? 1}`;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 开始一个本地诊断阶段;父阶段包含子阶段,不能相加作为总耗时。
|
|
5
|
+
* @param {string} scope 固定操作名,不得传入用户路径或参数
|
|
6
|
+
* @param {string} stage 固定阶段名
|
|
7
|
+
* @param {{env?:object,output?:{write:Function},now?:Function}} [options] 运行环境与测试替身
|
|
8
|
+
* @returns {(success?:boolean)=>void} 幂等结束函数
|
|
9
|
+
*/
|
|
10
|
+
export function beginOperationTiming(scope, stage, options = {}) {
|
|
11
|
+
if ((options.env || process.env).FLOWER_TIMING !== "1") return () => {};
|
|
12
|
+
const output = options.output || process.stderr;
|
|
13
|
+
const now = options.now || (() => performance.now());
|
|
14
|
+
const started = now();
|
|
15
|
+
let finished = false;
|
|
16
|
+
const write = (message) => {
|
|
17
|
+
try { output.write(message); } catch { /* 诊断流失效不能影响升级结果。 */ }
|
|
18
|
+
};
|
|
19
|
+
write(` · [耗时 ${scope}/${stage}] 开始\n`);
|
|
20
|
+
return (success = true) => {
|
|
21
|
+
if (finished) return;
|
|
22
|
+
finished = true;
|
|
23
|
+
write(` · [耗时 ${scope}/${stage}] ${success ? "完成" : "失败"} ${Math.max(0, Math.round(now() - started))} ms\n`);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 计时同步或异步操作,保留返回值、退出码和原始异常。
|
|
29
|
+
* @template T
|
|
30
|
+
* @param {string} scope 固定操作名
|
|
31
|
+
* @param {string} stage 固定阶段名
|
|
32
|
+
* @param {()=>T} operation 原操作
|
|
33
|
+
* @param {object} [options] 计时依赖
|
|
34
|
+
* @returns {T} 原操作的返回值
|
|
35
|
+
*/
|
|
36
|
+
export function timeOperation(scope, stage, operation, options = {}) {
|
|
37
|
+
const finish = beginOperationTiming(scope, stage, options);
|
|
38
|
+
const complete = (value) => {
|
|
39
|
+
const code = typeof value === "number" ? value : value?.status;
|
|
40
|
+
finish(value !== null && value?.ok !== false && !value?.error && !value?.signal && (typeof code !== "number" || code === 0));
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
const fail = (error) => { finish(false); throw error; };
|
|
44
|
+
try {
|
|
45
|
+
const result = operation();
|
|
46
|
+
return result && typeof result.then === "function" ? result.then(complete, fail) : complete(result);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return fail(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/lib/self-check.js
CHANGED
|
@@ -4,10 +4,11 @@ import path from "node:path";
|
|
|
4
4
|
import { readManifest, readUpdateCheck, writeUpdateCheck } from "./manifest.js";
|
|
5
5
|
import {
|
|
6
6
|
buildReleaseNotesSummary,
|
|
7
|
-
|
|
7
|
+
createUpdateMetadataReader,
|
|
8
8
|
getUpdateRecommendation,
|
|
9
9
|
isPrerelease,
|
|
10
10
|
} from "./update-check.js";
|
|
11
|
+
import { isRemoteCacheFresh, cachedReleaseNotes } from "./update-check-cache.js";
|
|
11
12
|
import { isRunningViaNpx } from "./runtime-env.js";
|
|
12
13
|
import { flowerVersion, trellisVersion } from "./versions.js";
|
|
13
14
|
import { ProjectStore } from "../plugin/state/project-store.js";
|
|
@@ -37,48 +38,6 @@ function readProjectTrellisVersion(target) {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/** 判断远程探测缓存是否仍在 interval 内。 */
|
|
41
|
-
function isRemoteCacheFresh(updateCheck, now = new Date()) {
|
|
42
|
-
if (updateCheck.lastStatus === "offline" || updateCheck.lastErrorCode) return false;
|
|
43
|
-
if (!updateCheck.lastRemote) return false;
|
|
44
|
-
if (!updateCheck.lastCheckedAt) return false;
|
|
45
|
-
const checkedAt = new Date(updateCheck.lastCheckedAt).getTime();
|
|
46
|
-
if (!Number.isFinite(checkedAt)) return false;
|
|
47
|
-
return now.getTime() - checkedAt < updateCheck.intervalHours * 60 * 60 * 1000;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 判断缓存的 release notes 是否匹配本次检查范围。
|
|
52
|
-
*
|
|
53
|
-
* @param {object} updateCheck 归一化 updateCheck 配置
|
|
54
|
-
* @param {{from:string|null,to:string|null,channel:string,reason:string}|null} range 本次期望范围
|
|
55
|
-
* @returns {object|null} 可复用的缓存摘要
|
|
56
|
-
*/
|
|
57
|
-
function cachedReleaseNotes(updateCheck, range) {
|
|
58
|
-
const cached = updateCheck.lastReleaseNotes;
|
|
59
|
-
if (!cached || !range || cached.unavailable) return null;
|
|
60
|
-
const cachedRange = cached.range || {};
|
|
61
|
-
if (
|
|
62
|
-
cachedRange.from !== range.from ||
|
|
63
|
-
cachedRange.to !== range.to ||
|
|
64
|
-
cachedRange.channel !== range.channel
|
|
65
|
-
) {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
if (!Array.isArray(cached.versions) || !cached.versions.length) return null;
|
|
69
|
-
// 同一版本范围的内容相同;reason 只是触发路径,不能让项目追平场景丢失摘要。
|
|
70
|
-
return {
|
|
71
|
-
...cached,
|
|
72
|
-
range: {
|
|
73
|
-
...cachedRange,
|
|
74
|
-
from: range.from,
|
|
75
|
-
to: range.to,
|
|
76
|
-
channel: range.channel,
|
|
77
|
-
reason: range.reason,
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
41
|
/**
|
|
83
42
|
* 构造新版升级 release notes 范围。
|
|
84
43
|
*
|
|
@@ -544,7 +503,7 @@ export function safetyState(target, status, command) {
|
|
|
544
503
|
* 构建启动自更新检查结果。
|
|
545
504
|
*
|
|
546
505
|
* @param {string} target 目标项目根
|
|
547
|
-
* @param {{writeCache?:
|
|
506
|
+
* @param {{writeCache?:boolean,forceRemote?:boolean,fetchMetadata?:Function,fetchTags?:Function,fetchImpl?:Function,timeoutMs?:number,onRemoteCheck?:()=>Promise<unknown>,recordPrompt?:boolean,ignorePromptSuppression?:boolean}} [options] 检查选项
|
|
548
507
|
* @returns {Promise<object>} 结构化检查结果
|
|
549
508
|
*/
|
|
550
509
|
export async function buildSelfCheck(target, options = {}) {
|
|
@@ -552,9 +511,8 @@ export async function buildSelfCheck(target, options = {}) {
|
|
|
552
511
|
const forceRemote = options.forceRemote === true;
|
|
553
512
|
const recordPrompt = options.recordPrompt === true;
|
|
554
513
|
const ignorePromptSuppression = options.ignorePromptSuppression === true;
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
: fetchPackageUpdateMetadata;
|
|
514
|
+
const reader = createUpdateMetadataReader(options);
|
|
515
|
+
const fetchMetadata = reader.readMetadata;
|
|
558
516
|
const onRemoteCheck = typeof options.onRemoteCheck === "function"
|
|
559
517
|
? options.onRemoteCheck
|
|
560
518
|
: null;
|
|
@@ -698,11 +656,11 @@ export async function buildSelfCheck(target, options = {}) {
|
|
|
698
656
|
};
|
|
699
657
|
}
|
|
700
658
|
|
|
701
|
-
const [
|
|
702
|
-
|
|
659
|
+
const [remoteTags] = await Promise.all([
|
|
660
|
+
reader.readTags(),
|
|
703
661
|
onRemoteCheck ? Promise.resolve().then(onRemoteCheck).catch(() => null) : null,
|
|
704
662
|
]);
|
|
705
|
-
if (!
|
|
663
|
+
if (!remoteTags) {
|
|
706
664
|
const resultBase = persistRemoteCache({
|
|
707
665
|
lastStatus: "offline",
|
|
708
666
|
lastErrorCode: "fetch_failed",
|
|
@@ -726,17 +684,19 @@ export async function buildSelfCheck(target, options = {}) {
|
|
|
726
684
|
};
|
|
727
685
|
}
|
|
728
686
|
|
|
729
|
-
tags =
|
|
687
|
+
tags = remoteTags;
|
|
730
688
|
const recommendation = getUpdateRecommendation(currentFlower, tags);
|
|
731
689
|
const remoteStatus = recommendation ? "update_available" : "up_to_date";
|
|
732
690
|
const releaseNotesRange = recommendation
|
|
733
691
|
? updateReleaseNotesRange(currentFlower, recommendation)
|
|
734
|
-
: projectReleaseNotesRange(projectFlower, currentFlower);
|
|
735
|
-
const releaseNotes =
|
|
692
|
+
: projectOutOfSync ? projectReleaseNotesRange(projectFlower, currentFlower) : null;
|
|
693
|
+
const releaseNotes = cachedReleaseNotes(updateCheck, releaseNotesRange) || (releaseNotesRange
|
|
694
|
+
? releaseNotesFromMetadata(await fetchMetadata(), releaseNotesRange)
|
|
695
|
+
: null);
|
|
736
696
|
const resultBase = persistRemoteCache({
|
|
737
697
|
lastCheckedAt: now.toISOString(),
|
|
738
698
|
lastRemote: tags,
|
|
739
|
-
|
|
699
|
+
...(releaseNotes && !releaseNotes.unavailable ? { lastReleaseNotes: releaseNotes } : {}),
|
|
740
700
|
lastStatus: remoteStatus,
|
|
741
701
|
lastErrorCode: null,
|
|
742
702
|
});
|