flower-trellis 0.6.6 → 0.6.7
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 +12 -4
- package/enhancements/0.6/.agents/skills/trellis-check-all/SKILL.md +6 -31
- package/enhancements/0.6/.agents/skills/trellis-check-all/references/full-profile.md +10 -49
- package/enhancements/0.6/.agents/skills/trellis-check-all/references/light-profile.md +9 -28
- package/enhancements/0.6/.agents/skills/trellis-check-all/references/maven-evidence.md +14 -0
- package/enhancements/0.6/.agents/skills/trellis-check-all/references/reporting-and-disposition.md +10 -1
- package/enhancements/0.6/.agents/skills/trellis-check-all/references/verification.md +29 -0
- package/enhancements/0.6/.agents/skills/trellis-flower-update/SKILL.md +8 -5
- package/enhancements/0.6/.agents/skills/trellis-route/SKILL.md +1 -1
- package/enhancements/0.6/.agents/skills/trellis-worktree/SKILL.md +20 -8
- package/enhancements/0.6/.claude/skills/trellis-check-all/SKILL.md +6 -31
- package/enhancements/0.6/.claude/skills/trellis-check-all/references/full-profile.md +10 -49
- package/enhancements/0.6/.claude/skills/trellis-check-all/references/light-profile.md +9 -28
- package/enhancements/0.6/.claude/skills/trellis-check-all/references/maven-evidence.md +14 -0
- package/enhancements/0.6/.claude/skills/trellis-check-all/references/reporting-and-disposition.md +10 -1
- package/enhancements/0.6/.claude/skills/trellis-check-all/references/verification.md +29 -0
- package/enhancements/0.6/.claude/skills/trellis-flower-update/SKILL.md +8 -5
- package/enhancements/0.6/.claude/skills/trellis-route/SKILL.md +1 -1
- package/enhancements/0.6/.claude/skills/trellis-worktree/SKILL.md +20 -8
- package/enhancements/0.6/scripts/worktree_setup.py +119 -17
- package/enhancements/MANIFEST.json +2 -2
- package/package.json +3 -3
- package/src/assets/flower_update_hook.py +135 -14
- package/src/builtin-plugins/skill-garden/content-adapter.js +4 -0
- package/src/builtin-plugins/skill-garden/project-metadata.js +41 -0
- package/src/commands/self-check.js +3 -1
- package/src/commands/self-update.js +3 -1
- package/src/commands/update-check.js +3 -3
- package/src/commands/worktree.js +36 -5
- package/src/lib/apply-enhancements.js +1 -1
- package/src/lib/manifest.js +46 -43
- package/src/lib/self-check.js +8 -4
- package/src/lib/worktree-flower-state.js +347 -0
- package/src/plugin/application-service.js +1 -44
- package/src/plugin/install/state-validation.js +47 -0
- package/src/plugin/install/transaction-writer.js +23 -2
- package/src/plugin/state/ignore-rules.js +40 -0
- package/src/plugin/state/project-store.js +3 -25
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import semver from "semver";
|
|
7
|
+
import { REQUIRED_IGNORE_RULES } from "../plugin/state/project-store.js";
|
|
8
|
+
import { validatePluginsFile, validatePluginLock, validatePluginState } from "../plugin/schemas/project-files.js";
|
|
9
|
+
import { assertSafePosixRelativePath } from "../plugin/schemas/shared.js";
|
|
10
|
+
import { stringifyCanonicalJson } from "../plugin/integrity/canonical-json.js";
|
|
11
|
+
import { classifyLockReachability } from "../plugin/lock-reachability.js";
|
|
12
|
+
import { contentSelectionsEqual } from "../plugin/content-selection.js";
|
|
13
|
+
import { hashContent, hashDirectoryIfExists } from "../plugin/install/content-hash.js";
|
|
14
|
+
import { assertPluginStateMatches } from "../plugin/install/state-validation.js";
|
|
15
|
+
import { normalizeUpdateCheckPolicy } from "./manifest.js";
|
|
16
|
+
|
|
17
|
+
const RECORDS = ["plugins.json", "plugin-lock.json", "state.json"];
|
|
18
|
+
const FILES = [...RECORDS, "settings.json"];
|
|
19
|
+
|
|
20
|
+
/** 构造含稳定原因和路径的继承错误。 */
|
|
21
|
+
function failure(reason, message, relative = null) {
|
|
22
|
+
return Object.assign(new Error(message), { reason, path: relative });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 校验完整路径链,拒绝祖先目录和末级软链接。 */
|
|
26
|
+
function ordinaryPath(root, relative) {
|
|
27
|
+
assertSafePosixRelativePath(relative, "worktree Flower 路径");
|
|
28
|
+
const parts = relative.split("/");
|
|
29
|
+
let current = path.resolve(root);
|
|
30
|
+
for (let index = -1; index < parts.length; index += 1) {
|
|
31
|
+
if (index >= 0) current = path.join(current, parts[index]);
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.lstatSync(current);
|
|
34
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory())) {
|
|
35
|
+
throw failure("flower-path-conflict", `Flower 路径不能包含软链或非目录:${relative}`, relative);
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === "ENOENT") break;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return path.join(root, ...parts);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 只读普通文件,缺失与损坏分开处理。 */
|
|
46
|
+
function readText(root, relative) {
|
|
47
|
+
const file = ordinaryPath(root, relative);
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.lstatSync(file).isFile()) throw failure("flower-path-conflict", `Flower 记录必须是普通文件:${relative}`, relative);
|
|
50
|
+
return fs.readFileSync(file, "utf8");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === "ENOENT") return null;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 执行只读 Git 查询,失败不能解释为缺少安装记录。 */
|
|
58
|
+
function git(root, args) {
|
|
59
|
+
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10000 });
|
|
60
|
+
if (result.error || result.status !== 0) throw failure("flower-git-error", `无法查询 Flower 所属 Git 仓库:${result.error?.message || result.stderr}`);
|
|
61
|
+
return result.stdout;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 从目标 base commit 读取普通 blob,不创建临时 checkout。 */
|
|
65
|
+
function readBaseText(source, commit, relative) {
|
|
66
|
+
const parts = relative.split("/");
|
|
67
|
+
for (let length = 1; length <= parts.length; length += 1) {
|
|
68
|
+
const entry = git(source, ["ls-tree", "-z", commit, "--", parts.slice(0, length).join("/")]);
|
|
69
|
+
if (!entry) return null;
|
|
70
|
+
const mode = entry.slice(0, 6);
|
|
71
|
+
if ((length < parts.length && mode !== "040000") || (length === parts.length && !["100644", "100755"].includes(mode))) {
|
|
72
|
+
throw failure("flower-path-conflict", `base 中的 Flower 路径不是普通文件:${relative}`, relative);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return git(source, ["show", `${commit}:${relative}`]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 读取白名单记录及其原始字节;用于指纹检测未提交记录变化。 */
|
|
79
|
+
function snapshot(root, source, baseCommit) {
|
|
80
|
+
const read = baseCommit ? (relative) => readBaseText(source, baseCommit, relative) : (relative) => readText(root, relative);
|
|
81
|
+
const texts = Object.fromEntries(FILES.map((name) => [name, read(`.flower/${name}`)]));
|
|
82
|
+
if (read(".flower/trellis-control.json") !== null) throw failure("flower-control-active", "Flower 来源或目标处于禁用/恢复状态,不能继承安装记录", ".flower/trellis-control.json");
|
|
83
|
+
const values = {};
|
|
84
|
+
for (const [name, text] of Object.entries(texts)) {
|
|
85
|
+
if (text === null) { values[name] = null; continue; }
|
|
86
|
+
try {
|
|
87
|
+
values[name] = JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
throw failure("flower-record-invalid", `Flower 安装状态 JSON 损坏:.flower/${name}`, `.flower/${name}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const [name, validate] of [["plugins.json", validatePluginsFile], ["plugin-lock.json", validatePluginLock], ["state.json", validatePluginState]]) {
|
|
93
|
+
if (texts[name] !== null) validate(values[name]);
|
|
94
|
+
}
|
|
95
|
+
if (texts["settings.json"] !== null) {
|
|
96
|
+
const settings = values["settings.json"];
|
|
97
|
+
if (!settings || settings.schemaVersion !== 1 || !settings.updateCheck || typeof settings.updateCheck !== "object" || Array.isArray(settings.updateCheck)) {
|
|
98
|
+
throw failure("flower-settings-invalid", "Flower settings 结构无效", ".flower/settings.json");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { texts, values };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 验证完整安装集合及可选的当前内容,不访问包缓存或远端。 */
|
|
105
|
+
function validateInstallation(root, values, content = true) {
|
|
106
|
+
const declarations = values["plugins.json"].plugins;
|
|
107
|
+
const lock = values["plugin-lock.json"];
|
|
108
|
+
const state = values["state.json"];
|
|
109
|
+
const equalIds = (left, right) => JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
110
|
+
const reachability = classifyLockReachability(declarations, lock);
|
|
111
|
+
if (!equalIds(declarations.map(({ id }) => id), lock.roots) || reachability.missingIds.size || reachability.orphanIds.size ||
|
|
112
|
+
!equalIds(lock.plugins.map(({ id }) => id), state.plugins.map(({ id }) => id))) {
|
|
113
|
+
throw failure("flower-installation-conflict", "Flower 声明、lock 与 state 的插件集合冲突", ".flower/state.json");
|
|
114
|
+
}
|
|
115
|
+
for (const locked of lock.plugins) {
|
|
116
|
+
const applied = state.plugins.find(({ id }) => id === locked.id);
|
|
117
|
+
const declaration = declarations.find(({ id }) => id === locked.id);
|
|
118
|
+
if (applied.version !== locked.version || !contentSelectionsEqual(applied.contentSelection, locked.contentSelection) ||
|
|
119
|
+
(declaration && (!semver.satisfies(locked.version, declaration.version) ||
|
|
120
|
+
!contentSelectionsEqual(declaration.contentSelection, locked.contentSelection) ||
|
|
121
|
+
(declaration.platforms?.length && !equalIds(declaration.platforms, applied.platforms))))) {
|
|
122
|
+
throw failure("flower-installation-conflict", `Flower 安装约束冲突:${locked.id}`, ".flower/state.json");
|
|
123
|
+
}
|
|
124
|
+
for (const [id, range] of Object.entries(locked.dependencies)) {
|
|
125
|
+
if (!semver.satisfies(lock.plugins.find((entry) => entry.id === id).version, range)) {
|
|
126
|
+
throw failure("flower-installation-conflict", `Flower 依赖版本冲突:${id}`, ".flower/plugin-lock.json");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!content) continue;
|
|
130
|
+
if (locked.compatibility.trellis) {
|
|
131
|
+
const version = readText(root, ".trellis/.version")?.trim();
|
|
132
|
+
if (!version || !semver.satisfies(version, locked.compatibility.trellis)) {
|
|
133
|
+
throw failure("flower-trellis-incompatible", `目标 Trellis 版本不满足 Plugin 兼容范围:${locked.id}`, ".trellis/.version");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const relative of [...applied.paths.map((entry) => entry.path), ...applied.patches.map((entry) => entry.target)]) ordinaryPath(root, relative);
|
|
137
|
+
assertPluginStateMatches(root, locked, applied);
|
|
138
|
+
if (locked.source.type === "local") {
|
|
139
|
+
const local = ordinaryPath(root, locked.source.reference);
|
|
140
|
+
if (hashDirectoryIfExists(local) === null) throw failure("flower-local-source-missing", `目标缺少本地 Plugin 来源:${locked.source.reference}`, locked.source.reference);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 构造内部载荷和公开计划,完整目标校验在真实 checkout 后执行。 */
|
|
146
|
+
function transfer(input) {
|
|
147
|
+
const { source, target, baseCommit = null, sourceDeveloper = null, targetDeveloper = null } = input;
|
|
148
|
+
if (!baseCommit) {
|
|
149
|
+
const sourceCommon = fs.realpathSync(git(source, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim());
|
|
150
|
+
const targetCommon = fs.realpathSync(git(target, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim());
|
|
151
|
+
if (sourceCommon !== targetCommon) throw failure("flower-repository-mismatch", "Flower 来源与目标必须属于同一 Git 仓库");
|
|
152
|
+
}
|
|
153
|
+
const destination = snapshot(target, source, baseCommit);
|
|
154
|
+
const complete = RECORDS.every((name) => destination.values[name] !== null);
|
|
155
|
+
const origin = complete ? null : snapshot(source);
|
|
156
|
+
const digest = hashContent(stringifyCanonicalJson({ source: path.resolve(source), target: path.resolve(target), sourceDeveloper, targetDeveloper, origin: origin?.texts || null, destination: destination.texts }));
|
|
157
|
+
const plan = { action: "preserved", source: complete ? null : source, paths: [], digest, validationPending: Boolean(baseCommit), reason: null };
|
|
158
|
+
if (complete) {
|
|
159
|
+
validateInstallation(target, destination.values, !baseCommit);
|
|
160
|
+
return { plan, files: [] };
|
|
161
|
+
}
|
|
162
|
+
if (RECORDS.some((name) => origin.values[name] === null)) {
|
|
163
|
+
return { plan: { ...plan, action: "unavailable", reason: "source-installation-missing" }, files: [] };
|
|
164
|
+
}
|
|
165
|
+
validateInstallation(source, origin.values);
|
|
166
|
+
for (const name of RECORDS) {
|
|
167
|
+
if (destination.values[name] !== null && stringifyCanonicalJson(destination.values[name]) !== stringifyCanonicalJson(origin.values[name])) {
|
|
168
|
+
throw failure("flower-installation-conflict", `目标已有 Flower 记录与来源冲突:.flower/${name}`, `.flower/${name}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
validateInstallation(target, origin.values, !baseCommit);
|
|
172
|
+
if (!baseCommit) {
|
|
173
|
+
for (const locked of origin.values["plugin-lock.json"].plugins.filter((entry) => entry.source.type === "local")) {
|
|
174
|
+
const relative = locked.source.reference;
|
|
175
|
+
if (hashDirectoryIfExists(ordinaryPath(source, relative)) !== hashDirectoryIfExists(ordinaryPath(target, relative))) {
|
|
176
|
+
throw failure("flower-local-source-conflict", `本地 Plugin 来源内容冲突:${relative}`, relative);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const files = RECORDS.filter((name) => destination.texts[name] === null).map((name) => ({ path: `.flower/${name}`, content: stringifyCanonicalJson(origin.values[name]) }));
|
|
181
|
+
if (destination.texts["settings.json"] === null && origin.values["settings.json"] && sourceDeveloper && sourceDeveloper === targetDeveloper) {
|
|
182
|
+
files.unshift({ path: ".flower/settings.json", content: stringifyCanonicalJson({ schemaVersion: 1, updateCheck: normalizeUpdateCheckPolicy(origin.values["settings.json"].updateCheck) }) });
|
|
183
|
+
}
|
|
184
|
+
const ignore = baseCommit ? readBaseText(source, baseCommit, ".flower/.gitignore") : readText(target, ".flower/.gitignore");
|
|
185
|
+
if (!baseCommit && ignore !== null && destination.texts["state.json"] === null) {
|
|
186
|
+
const ignored = spawnSync("git", ["-C", target, "check-ignore", "--no-index", "-q", ".flower/state.json"], { timeout: 10000 });
|
|
187
|
+
if (ignored.error || ignored.status !== 0) throw failure("flower-ignore-conflict", "现有忽略规则未覆盖 .flower/state.json,请先完善目标忽略规则", ".flower/.gitignore");
|
|
188
|
+
}
|
|
189
|
+
if (ignore === null) files.unshift({ path: ".flower/.gitignore", content: `${REQUIRED_IGNORE_RULES.join("\n")}\n` });
|
|
190
|
+
return { plan: { ...plan, action: "inherited", paths: files.map((file) => file.path), digest: hashContent(`${digest}\n${ignore ?? ""}`) }, files };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 只读计算同仓 worktree 的 Flower 继承计划。
|
|
195
|
+
* @param {object} input 来源、目标、可选 baseCommit 及开发者身份
|
|
196
|
+
* @returns {object} 不含配置原文的继承计划
|
|
197
|
+
*/
|
|
198
|
+
export function planFlowerTransfer(input) {
|
|
199
|
+
return transfer(input).plan;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 将回执原子存入 engine 提供的项目外临时文件,支持子进程失败后恢复。 */
|
|
203
|
+
function saveReceipt(journal, receipt) {
|
|
204
|
+
if (!journal) return;
|
|
205
|
+
const temporary = `${journal}.tmp`;
|
|
206
|
+
fs.writeFileSync(temporary, JSON.stringify(receipt), { mode: 0o600 });
|
|
207
|
+
fs.renameSync(temporary, journal);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 仅清理回执中仍为本操作所创建且未被修改的文件。
|
|
212
|
+
* @param {string} target 目标根
|
|
213
|
+
* @param {object} receipt 本次写入回执
|
|
214
|
+
* @returns {void}
|
|
215
|
+
*/
|
|
216
|
+
export function rollbackFlowerTransfer(target, receipt) {
|
|
217
|
+
if (!receipt || receipt.target !== path.resolve(target)) throw failure("flower-rollback-invalid", "Flower 回滚回执与目标不一致");
|
|
218
|
+
const errors = [];
|
|
219
|
+
for (const entry of [...receipt.files].reverse()) {
|
|
220
|
+
for (const relative of [entry.path, entry.temporary].filter(Boolean)) {
|
|
221
|
+
try {
|
|
222
|
+
const allowed = [...FILES, ".gitignore"].some((name) => relative === `.flower/${name}`) || /^\.flower\/\.inherit-[a-f0-9]{16}\.tmp$/.test(relative);
|
|
223
|
+
if (!allowed) throw new Error("回执路径不在白名单");
|
|
224
|
+
const file = ordinaryPath(target, relative);
|
|
225
|
+
const stat = fs.lstatSync(file);
|
|
226
|
+
if (stat.dev !== entry.dev || stat.ino !== entry.ino || hashContent(fs.readFileSync(file)) !== entry.hash) {
|
|
227
|
+
throw new Error(`Flower 文件已由其它操作修改:${relative}`);
|
|
228
|
+
}
|
|
229
|
+
fs.unlinkSync(file);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error.code !== "ENOENT") errors.push(error.message);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (receipt.directory) {
|
|
236
|
+
try {
|
|
237
|
+
const directory = ordinaryPath(target, ".flower");
|
|
238
|
+
const stat = fs.lstatSync(directory);
|
|
239
|
+
if (stat.dev === receipt.directory.dev && stat.ino === receipt.directory.ino) fs.rmdirSync(directory);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) errors.push(error.message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (errors.length) throw failure("flower-rollback-conflict", errors.join("; "));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 校验后补齐缺失安装记录,失败时撤销本次文件写入。
|
|
249
|
+
* @param {object} input 继承参数,含可选 expectedDigest、journal 和测试回调 onWrite
|
|
250
|
+
* @returns {object} 继承结果和本轮回执
|
|
251
|
+
*/
|
|
252
|
+
export function applyFlowerTransfer(input) {
|
|
253
|
+
const planned = transfer(input);
|
|
254
|
+
if (input.expectedDigest && input.expectedDigest !== planned.plan.digest) throw failure("flower-plan-changed", "Flower 继承计划已变化,请重新预检");
|
|
255
|
+
const { plan, files } = input.baseCommit ? transfer({ ...input, baseCommit: null }) : planned;
|
|
256
|
+
if (plan.digest !== planned.plan.digest) throw failure("flower-plan-changed", "目标 Flower 继承计划已变化,请重新预检");
|
|
257
|
+
const receipt = { target: path.resolve(input.target), files: [], directory: null };
|
|
258
|
+
saveReceipt(input.journal, receipt);
|
|
259
|
+
if (!files.length) return { ...plan, receipt };
|
|
260
|
+
try {
|
|
261
|
+
const directory = ordinaryPath(input.target, ".flower");
|
|
262
|
+
if (!fs.existsSync(directory)) {
|
|
263
|
+
fs.mkdirSync(directory);
|
|
264
|
+
const stat = fs.statSync(directory);
|
|
265
|
+
receipt.directory = { dev: stat.dev, ino: stat.ino };
|
|
266
|
+
saveReceipt(input.journal, receipt);
|
|
267
|
+
}
|
|
268
|
+
for (const entry of files) {
|
|
269
|
+
const target = ordinaryPath(input.target, entry.path);
|
|
270
|
+
const temporary = path.join(directory, `.inherit-${crypto.randomBytes(8).toString("hex")}.tmp`);
|
|
271
|
+
let linked = false;
|
|
272
|
+
try {
|
|
273
|
+
const descriptor = fs.openSync(temporary, "wx", 0o600);
|
|
274
|
+
try {
|
|
275
|
+
fs.writeFileSync(descriptor, entry.content);
|
|
276
|
+
fs.fsyncSync(descriptor);
|
|
277
|
+
const stat = fs.fstatSync(descriptor);
|
|
278
|
+
receipt.files.push({ path: entry.path, temporary: `.flower/${path.basename(temporary)}`, hash: hashContent(entry.content), dev: stat.dev, ino: stat.ino });
|
|
279
|
+
saveReceipt(input.journal, receipt);
|
|
280
|
+
} finally { fs.closeSync(descriptor); }
|
|
281
|
+
// 同目录临时文件先完整写入,再以排他 link 发布,避免 rename 覆盖并发创建的目标。
|
|
282
|
+
fs.linkSync(temporary, target);
|
|
283
|
+
linked = true;
|
|
284
|
+
} finally {
|
|
285
|
+
fs.rmSync(temporary, { force: true });
|
|
286
|
+
if (!linked) {
|
|
287
|
+
receipt.files = receipt.files.filter((file) => file.path !== entry.path);
|
|
288
|
+
saveReceipt(input.journal, receipt);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
input.onWrite?.(entry.path);
|
|
292
|
+
}
|
|
293
|
+
validateInstallation(input.target, snapshot(input.target).values);
|
|
294
|
+
return { ...plan, validationPending: false, receipt };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
try { rollbackFlowerTransfer(input.target, receipt); }
|
|
297
|
+
catch (rollbackError) { error.message += `; 回滚失败:${rollbackError.message}`; }
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 只读诊断目标 Flower 版本证据及安装记录完整性。
|
|
304
|
+
* @param {string} target 目标根
|
|
305
|
+
* @returns {object} 安装状态、版本来源和具体错误
|
|
306
|
+
*/
|
|
307
|
+
export function inspectWorktreeFlower(target) {
|
|
308
|
+
let version = null;
|
|
309
|
+
let source = null;
|
|
310
|
+
try {
|
|
311
|
+
const { values } = snapshot(target);
|
|
312
|
+
version = values["plugin-lock.json"]?.plugins.find(({ id }) => id === "flower/skill-garden")?.version || null;
|
|
313
|
+
if (version) source = "plugin-lock";
|
|
314
|
+
if (!version) {
|
|
315
|
+
const legacy = readText(target, ".trellis/.flower-manifest.json");
|
|
316
|
+
if (legacy !== null) {
|
|
317
|
+
const manifest = JSON.parse(legacy);
|
|
318
|
+
version = typeof manifest?.flowerVersion === "string" && manifest.flowerVersion.trim() ? manifest.flowerVersion : null;
|
|
319
|
+
if (version) source = "legacy-manifest";
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const complete = RECORDS.every((name) => values[name] !== null);
|
|
323
|
+
if (complete) validateInstallation(target, values);
|
|
324
|
+
return { installation: complete ? "complete" : "incomplete", version, source, reason: complete ? null : "flower-records-missing" };
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return { installation: "invalid", version, source, reason: error.reason || "flower-record-invalid", path: error.path || null, message: error.message };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 仅 Flower facade 提供本入口;目标分支脚本不能替换 schema 或写入实现。
|
|
331
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
332
|
+
try {
|
|
333
|
+
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
334
|
+
let result;
|
|
335
|
+
if (input.operation === "status") result = inspectWorktreeFlower(input.target);
|
|
336
|
+
else if (input.operation === "plan") result = planFlowerTransfer(input);
|
|
337
|
+
else if (input.operation === "apply") result = applyFlowerTransfer(input);
|
|
338
|
+
else if (input.operation === "rollback") {
|
|
339
|
+
if (fs.existsSync(input.journal)) rollbackFlowerTransfer(input.target, JSON.parse(fs.readFileSync(input.journal, "utf8")));
|
|
340
|
+
result = { action: "rolledBack" };
|
|
341
|
+
} else throw failure("flower-operation-invalid", "未知 Flower worktree 内部操作");
|
|
342
|
+
console.log(JSON.stringify({ ok: true, result }));
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.log(JSON.stringify({ ok: false, reason: error.reason || "flower-installation-invalid", message: error.message, path: error.path || null }));
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -13,6 +13,7 @@ import { projectPluginContent } from "./install/content-projector.js";
|
|
|
13
13
|
import { createInstallPlan } from "./install/install-planner.js";
|
|
14
14
|
import { TransactionWriter } from "./install/transaction-writer.js";
|
|
15
15
|
import { hashDirectoryIfExists, hashFileIfExists } from "./install/content-hash.js";
|
|
16
|
+
import { assertPluginStateMatches as assertPreservedState } from "./install/state-validation.js";
|
|
16
17
|
import { getPluginPatchPlanner } from "./runtime-extensions.js";
|
|
17
18
|
import { isRuntimeBuiltinProviderTrusted } from "./runtime-extensions.js";
|
|
18
19
|
import { compareUtf8 } from "./stable-order.js";
|
|
@@ -105,50 +106,6 @@ function standardContentPlan(graph) {
|
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
-
* 校验冻结 Plugin 的 lock/state 对应关系与当前目标摘要。
|
|
110
|
-
*
|
|
111
|
-
* @param {string} projectRoot 项目根
|
|
112
|
-
* @param {import("./contracts.js").ResolvedPlugin} locked 冻结 lock entry
|
|
113
|
-
* @param {import("./contracts.js").PluginStateEntry} applied 冻结 state entry
|
|
114
|
-
* @returns {void}
|
|
115
|
-
*/
|
|
116
|
-
function assertPreservedState(projectRoot, locked, applied) {
|
|
117
|
-
if (
|
|
118
|
-
applied.version !== locked.version ||
|
|
119
|
-
!contentSelectionsEqual(applied.contentSelection, locked.contentSelection)
|
|
120
|
-
) {
|
|
121
|
-
throw new PluginRuntimeError(`冻结 Plugin 的 lock/state 不一致:${locked.id}`, {
|
|
122
|
-
code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
|
|
123
|
-
path: locked.id,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
for (const entry of applied.paths) {
|
|
127
|
-
const target = path.join(projectRoot, ...entry.path.split("/"));
|
|
128
|
-
const actual = entry.kind === "directory"
|
|
129
|
-
? hashDirectoryIfExists(target)
|
|
130
|
-
: hashFileIfExists(target);
|
|
131
|
-
if (actual !== entry.hash) {
|
|
132
|
-
throw new PluginRuntimeError(`冻结 Plugin 目标摘要漂移:${entry.path}`, {
|
|
133
|
-
code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
|
|
134
|
-
path: entry.path,
|
|
135
|
-
details: { expected: entry.hash, actual },
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
for (const patchEntry of applied.patches) {
|
|
140
|
-
const target = path.join(projectRoot, ...patchEntry.target.split("/"));
|
|
141
|
-
const actual = hashFileIfExists(target);
|
|
142
|
-
if (actual !== patchEntry.resultHash) {
|
|
143
|
-
throw new PluginRuntimeError(`冻结 Plugin Patch 目标摘要漂移:${patchEntry.target}`, {
|
|
144
|
-
code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
|
|
145
|
-
path: patchEntry.target,
|
|
146
|
-
details: { expected: patchEntry.resultHash, actual },
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
109
|
/**
|
|
153
110
|
* 判断两个项目内路径是否相同或存在父子关系。
|
|
154
111
|
*
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { contentSelectionsEqual } from "../content-selection.js";
|
|
3
|
+
import { PLUGIN_RUNTIME_ERROR_CODES, PluginRuntimeError } from "../runtime-errors.js";
|
|
4
|
+
import { hashDirectoryIfExists, hashFileIfExists } from "./content-hash.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 校验 Plugin 的 lock/state 对应关系与当前目标摘要。
|
|
8
|
+
*
|
|
9
|
+
* @param {string} projectRoot 项目根
|
|
10
|
+
* @param {import("../contracts.js").ResolvedPlugin} locked 锁定记录
|
|
11
|
+
* @param {import("../contracts.js").PluginStateEntry} applied 安装记录
|
|
12
|
+
* @returns {void}
|
|
13
|
+
*/
|
|
14
|
+
export function assertPluginStateMatches(projectRoot, locked, applied) {
|
|
15
|
+
if (
|
|
16
|
+
applied.version !== locked.version ||
|
|
17
|
+
!contentSelectionsEqual(applied.contentSelection, locked.contentSelection)
|
|
18
|
+
) {
|
|
19
|
+
throw new PluginRuntimeError(`冻结 Plugin 的 lock/state 不一致:${locked.id}`, {
|
|
20
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
|
|
21
|
+
path: locked.id,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
for (const entry of applied.paths) {
|
|
25
|
+
const target = path.join(projectRoot, ...entry.path.split("/"));
|
|
26
|
+
const actual = entry.kind === "directory"
|
|
27
|
+
? hashDirectoryIfExists(target)
|
|
28
|
+
: hashFileIfExists(target);
|
|
29
|
+
if (actual !== entry.hash) {
|
|
30
|
+
throw new PluginRuntimeError(`冻结 Plugin 目标摘要漂移:${entry.path}`, {
|
|
31
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
|
|
32
|
+
path: entry.path,
|
|
33
|
+
details: { expected: entry.hash, actual },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const entry of applied.patches) {
|
|
38
|
+
const actual = hashFileIfExists(path.join(projectRoot, ...entry.target.split("/")));
|
|
39
|
+
if (actual !== entry.resultHash) {
|
|
40
|
+
throw new PluginRuntimeError(`冻结 Plugin Patch 目标摘要漂移:${entry.target}`, {
|
|
41
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
|
|
42
|
+
path: entry.target,
|
|
43
|
+
details: { expected: entry.resultHash, actual },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -173,9 +173,13 @@ export class TransactionWriter {
|
|
|
173
173
|
const completed = [];
|
|
174
174
|
let transactionCreated = false;
|
|
175
175
|
const manifestPath = path.join(transactionRoot, "transaction.json");
|
|
176
|
+
const ignorePath = this.#resolveTarget(realRoot, ".flower/.gitignore");
|
|
177
|
+
const ignoreBefore = hashFileIfExists(ignorePath) === null ? null : this.fileSystem.readFileSync(ignorePath);
|
|
178
|
+
let layoutStarted = false;
|
|
176
179
|
const transactionManifest = {
|
|
177
180
|
schemaVersion: 1,
|
|
178
181
|
id: transactionId,
|
|
182
|
+
layout: { path: ".flower/.gitignore", existed: ignoreBefore !== null, backup: "backup/layout-ignore.bin" },
|
|
179
183
|
targets: targetMutations.map(({ kind, owners, target, operation, beforeHash, afterHash }) => ({
|
|
180
184
|
kind,
|
|
181
185
|
owners: [...owners].sort(),
|
|
@@ -188,16 +192,23 @@ export class TransactionWriter {
|
|
|
188
192
|
};
|
|
189
193
|
|
|
190
194
|
try {
|
|
195
|
+
// ensureLayout 先于目标备份写忽略规则,必须独立保留首次写入前的字节。
|
|
196
|
+
layoutStarted = true;
|
|
191
197
|
this.store.ensureLayout();
|
|
192
198
|
this.fileSystem.mkdirSync(transactionRoot, { mode: 0o700 });
|
|
193
199
|
transactionCreated = true;
|
|
194
200
|
this.fileSystem.mkdirSync(stagingRoot);
|
|
195
201
|
this.fileSystem.mkdirSync(backupRoot);
|
|
202
|
+
if (ignoreBefore !== null) this.fileSystem.writeFileSync(path.join(backupRoot, "layout-ignore.bin"), ignoreBefore);
|
|
196
203
|
targetMutations.forEach((mutation, index) => {
|
|
197
204
|
const absoluteTarget = this.#resolveTarget(realRoot, mutation.target);
|
|
198
|
-
const
|
|
205
|
+
const isLayoutIgnore = mutation.target === ".flower/.gitignore";
|
|
206
|
+
const existed = isLayoutIgnore ? ignoreBefore !== null : this.fileSystem.existsSync(absoluteTarget);
|
|
199
207
|
const backupPath = path.join(backupRoot, `target-${index}.bin`);
|
|
200
|
-
if (existed)
|
|
208
|
+
if (existed) {
|
|
209
|
+
if (isLayoutIgnore) this.fileSystem.writeFileSync(backupPath, ignoreBefore);
|
|
210
|
+
else this.fileSystem.copyFileSync(absoluteTarget, backupPath);
|
|
211
|
+
}
|
|
201
212
|
targetBackups.push({ mutation, absoluteTarget, existed, backupPath });
|
|
202
213
|
if (mutation.operation === "write" && mutation.beforeHash !== mutation.afterHash) {
|
|
203
214
|
const stagingPath = path.join(stagingRoot, `target-${index}.bin`);
|
|
@@ -334,6 +345,16 @@ export class TransactionWriter {
|
|
|
334
345
|
rollbackFailures.push({ path: backup.mutation.target, error: rollbackError });
|
|
335
346
|
}
|
|
336
347
|
}
|
|
348
|
+
if (layoutStarted) {
|
|
349
|
+
try {
|
|
350
|
+
this.onOperation({ phase: "rollback", kind: "layout", path: ".flower/.gitignore", index: 0 });
|
|
351
|
+
this.#resolveTarget(realRoot, ".flower/.gitignore");
|
|
352
|
+
if (ignoreBefore !== null) this.#atomicWrite(ignorePath, ignoreBefore);
|
|
353
|
+
else this.fileSystem.rmSync(ignorePath, { force: true });
|
|
354
|
+
} catch (rollbackError) {
|
|
355
|
+
rollbackFailures.push({ path: ".flower/.gitignore", error: rollbackError });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
337
358
|
for (const directory of [...createdDirectories].sort((left, right) => right.length - left.length)) {
|
|
338
359
|
try {
|
|
339
360
|
this.fileSystem.rmdirSync(directory);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Flower 本机状态的标准局部忽略规则,兼容 worktree 准备入口。 */
|
|
2
|
+
export const REQUIRED_IGNORE_RULES = [
|
|
3
|
+
"state.json",
|
|
4
|
+
"cache/",
|
|
5
|
+
"transactions/",
|
|
6
|
+
"trellis-control.json",
|
|
7
|
+
"trellis-detached/",
|
|
8
|
+
"*.tmp",
|
|
9
|
+
"settings.json",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const SHARED_FILES = [".gitignore", "plugins.json", "plugin-lock.json"];
|
|
13
|
+
const BEGIN = "# BEGIN Flower shared records";
|
|
14
|
+
const END = "# END Flower shared records";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 更新末尾的 Flower 忽略块,保留块外字节并覆盖在后追加的冲突规则。
|
|
18
|
+
*
|
|
19
|
+
* @param {string} current 原忽略文件内容
|
|
20
|
+
* @param {{root?:boolean}} [options] 是否生成项目根规则
|
|
21
|
+
* @returns {string} 幂等合并后的内容
|
|
22
|
+
*/
|
|
23
|
+
export function mergeFlowerIgnoreRules(current, options = {}) {
|
|
24
|
+
const eol = current.match(/\r?\n/)?.[0] || "\n";
|
|
25
|
+
const starts = [...current.matchAll(/^# BEGIN Flower shared records\r?$/gm)];
|
|
26
|
+
const ends = [...current.matchAll(/^# END Flower shared records\r?$/gm)];
|
|
27
|
+
if (starts.length !== ends.length || starts.length > 1 || (starts.length && starts[0].index > ends[0].index)) {
|
|
28
|
+
throw new Error("Flower gitignore 标记不完整或重复,请先修复标记块");
|
|
29
|
+
}
|
|
30
|
+
let outside = current;
|
|
31
|
+
if (starts.length) {
|
|
32
|
+
const end = ends[0].index + ends[0][0].length;
|
|
33
|
+
outside = current.slice(0, starts[0].index) + current.slice(end + (current[end] === "\n" ? 1 : 0));
|
|
34
|
+
}
|
|
35
|
+
const rules = options.root
|
|
36
|
+
? ["!/.flower/", "/.flower/*", ...SHARED_FILES.map((name) => `!/.flower/${name}`)]
|
|
37
|
+
: [...REQUIRED_IGNORE_RULES, "*", ...SHARED_FILES.map((name) => `!${name}`)];
|
|
38
|
+
const prefix = outside && !outside.endsWith("\n") ? outside + eol : outside;
|
|
39
|
+
return prefix + [BEGIN, ...rules, END, ""].join(eol);
|
|
40
|
+
}
|
|
@@ -16,16 +16,10 @@ import {
|
|
|
16
16
|
validatePluginState,
|
|
17
17
|
} from "../schemas/project-files.js";
|
|
18
18
|
import { validateTrellisControlState } from "../schemas/trellis-control.js";
|
|
19
|
+
import { mergeFlowerIgnoreRules } from "./ignore-rules.js";
|
|
20
|
+
export { REQUIRED_IGNORE_RULES } from "./ignore-rules.js";
|
|
19
21
|
|
|
20
22
|
const FLOWER_DIR_NAME = ".flower";
|
|
21
|
-
const REQUIRED_IGNORE_RULES = [
|
|
22
|
-
"state.json",
|
|
23
|
-
"cache/",
|
|
24
|
-
"transactions/",
|
|
25
|
-
"trellis-control.json",
|
|
26
|
-
"trellis-detached/",
|
|
27
|
-
"*.tmp",
|
|
28
|
-
];
|
|
29
23
|
|
|
30
24
|
/**
|
|
31
25
|
* 判断 candidate 是否位于 root 内部或等于 root。
|
|
@@ -72,7 +66,7 @@ export class ProjectStore {
|
|
|
72
66
|
);
|
|
73
67
|
const gitignorePath = path.join(this.flowerDir, ".gitignore");
|
|
74
68
|
const existing = this.#readTextIfExists(gitignorePath);
|
|
75
|
-
const next =
|
|
69
|
+
const next = mergeFlowerIgnoreRules(existing || "");
|
|
76
70
|
const result = this.#atomicWriteText(gitignorePath, next);
|
|
77
71
|
return { flowerDir: this.flowerDir, gitignorePath, status: result.status };
|
|
78
72
|
}
|
|
@@ -335,22 +329,6 @@ export class ProjectStore {
|
|
|
335
329
|
}
|
|
336
330
|
}
|
|
337
331
|
|
|
338
|
-
/**
|
|
339
|
-
* 合并局部 `.gitignore` 必需规则并保留用户内容。
|
|
340
|
-
*
|
|
341
|
-
* @param {string} current 当前内容
|
|
342
|
-
* @returns {string} 幂等合并后的内容
|
|
343
|
-
*/
|
|
344
|
-
#mergeIgnoreRules(current) {
|
|
345
|
-
const normalized = current.replaceAll("\r\n", "\n");
|
|
346
|
-
const lines = normalized.split("\n");
|
|
347
|
-
const existing = new Set(lines.filter(Boolean));
|
|
348
|
-
const missing = REQUIRED_IGNORE_RULES.filter((rule) => !existing.has(rule));
|
|
349
|
-
if (missing.length === 0) return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
|
|
350
|
-
const prefix = normalized && !normalized.endsWith("\n") ? `${normalized}\n` : normalized;
|
|
351
|
-
return `${prefix}${missing.join("\n")}\n`;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
332
|
/**
|
|
355
333
|
* 使用同目录临时文件和 rename 原子替换文本。
|
|
356
334
|
*
|