flower-trellis 0.6.5 → 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/overrides/conflicts.json +4 -4
- package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/content.md +2 -4
- package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/planning-contract-content.md +1 -1
- package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/readiness-content.md +2 -2
- package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/review-content.md +2 -2
- 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_session_start.py +1 -1
- 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
package/src/commands/worktree.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { ENHANCEMENTS_ROOT } from "../lib/paths.js";
|
|
5
6
|
import {
|
|
6
7
|
resolveTrellisPythonCommand,
|
|
@@ -17,8 +18,9 @@ const VALUE_OPTIONS = new Set([
|
|
|
17
18
|
"--task-description",
|
|
18
19
|
"--developer",
|
|
19
20
|
"--plan-fingerprint",
|
|
21
|
+
"--source",
|
|
20
22
|
]);
|
|
21
|
-
const BOOLEAN_OPTIONS = new Set(["--json", "--dry-run", "--yes", "--inherit-route-prefs"]);
|
|
23
|
+
const BOOLEAN_OPTIONS = new Set(["--json", "--dry-run", "--yes", "--inherit-route-prefs", "--inherit-flower"]);
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* 识别 worktree 根级或子命令级帮助请求。
|
|
@@ -64,9 +66,12 @@ needs-migration 状态选择下一步。`);
|
|
|
64
66
|
用法:
|
|
65
67
|
flower-trellis worktree prepare [--target <dir>] [--developer <name>]
|
|
66
68
|
[--inherit-route-prefs] [--json]
|
|
69
|
+
[--inherit-flower --source <dir>]
|
|
67
70
|
|
|
68
71
|
prepare 不创建 Git worktree,只补齐目标分支本地的 developer、runtime 和 registry 状态。
|
|
69
|
-
仅在确实需要继承当前控制 worktree 的个人路由偏好时使用 --inherit-route-prefs
|
|
72
|
+
仅在确实需要继承当前控制 worktree 的个人路由偏好时使用 --inherit-route-prefs。
|
|
73
|
+
Flower 记录缺失时,用 --inherit-flower --source <同仓 worktree> 补齐可验证的独立安装信息。
|
|
74
|
+
即使 Trellis 已 ready-local,也可以运行此补配;已有目标记录不会被覆盖。`);
|
|
70
75
|
return;
|
|
71
76
|
}
|
|
72
77
|
if (command === "migrate") {
|
|
@@ -96,7 +101,9 @@ prepare 不创建 Git worktree,只补齐目标分支本地的 developer、runt
|
|
|
96
101
|
git worktree add <target> <existing-branch>
|
|
97
102
|
flower-trellis worktree status --target <target>
|
|
98
103
|
若 status 返回 needs-prepare,再运行:
|
|
99
|
-
flower-trellis worktree prepare --target <target> [--developer <name>]
|
|
104
|
+
flower-trellis worktree prepare --target <target> [--developer <name>]
|
|
105
|
+
create 自动将缺失的 Flower 安装信息纳入计划;目标内容不匹配时停止并回滚。
|
|
106
|
+
已有 worktree 缺少 Flower 记录时,使用 prepare --inherit-flower --source <source>。`);
|
|
100
107
|
return;
|
|
101
108
|
}
|
|
102
109
|
if (command === "remove") {
|
|
@@ -177,6 +184,15 @@ export function parseWorktreeArgs(args) {
|
|
|
177
184
|
if (command !== "prepare" && options.has("--inherit-route-prefs")) {
|
|
178
185
|
throw new Error("--inherit-route-prefs 只用于 worktree prepare");
|
|
179
186
|
}
|
|
187
|
+
if (command !== "prepare" && (options.has("--inherit-flower") || options.has("--source"))) {
|
|
188
|
+
throw new Error("--inherit-flower 和 --source 只用于 worktree prepare");
|
|
189
|
+
}
|
|
190
|
+
if (options.has("--inherit-flower") && !options.has("--source")) {
|
|
191
|
+
throw new Error("--inherit-flower 需要 --source 指定同仓来源");
|
|
192
|
+
}
|
|
193
|
+
if (options.has("--source") && !options.has("--inherit-flower")) {
|
|
194
|
+
throw new Error("--source 需要显式 --inherit-flower");
|
|
195
|
+
}
|
|
180
196
|
return { command, options, json: options.has("--json") };
|
|
181
197
|
}
|
|
182
198
|
|
|
@@ -194,7 +210,7 @@ export function worktreeEngineArgs(parsed, target, source) {
|
|
|
194
210
|
args.push("--source", source, "--target", target);
|
|
195
211
|
} else {
|
|
196
212
|
args.push("--target", target);
|
|
197
|
-
if (parsed.command === "prepare" && parsed.options.has("--inherit-route-prefs")) {
|
|
213
|
+
if (parsed.command === "prepare" && parsed.options.has("--inherit-route-prefs") && !parsed.options.has("--source")) {
|
|
198
214
|
args.push("--source", source);
|
|
199
215
|
}
|
|
200
216
|
}
|
|
@@ -244,6 +260,17 @@ export function printWorktreeResult(payload) {
|
|
|
244
260
|
if (payload.localStateTransfer?.routePreferences?.action) {
|
|
245
261
|
console.log(`route preferences: ${payload.localStateTransfer.routePreferences.action}`);
|
|
246
262
|
}
|
|
263
|
+
if (payload.localStateTransfer?.flower) {
|
|
264
|
+
const flower = payload.localStateTransfer.flower;
|
|
265
|
+
console.log(`flower transfer: ${flower.action}${flower.reason ? ` (${flower.reason})` : ""}`);
|
|
266
|
+
if (flower.source) console.log(`flower source: ${flower.source}`);
|
|
267
|
+
if (flower.paths?.length) console.log(`flower paths: ${flower.paths.join(", ")}`);
|
|
268
|
+
if (flower.validationPending) console.log("flower validation: checkout 后核验目标内容,失败回滚创建");
|
|
269
|
+
}
|
|
270
|
+
if (payload.flower) {
|
|
271
|
+
console.log(`flower: ${payload.flower.installation}; version: ${payload.flower.version || "unknown"}`);
|
|
272
|
+
if (payload.flower.reason) console.log(`flower reason: ${payload.flower.reason}`);
|
|
273
|
+
}
|
|
247
274
|
if (payload.requiresConfirmation && payload.confirmation?.fingerprint) {
|
|
248
275
|
console.log(`confirmation: 使用 --yes --plan-fingerprint ${payload.confirmation.fingerprint}`);
|
|
249
276
|
}
|
|
@@ -284,7 +311,11 @@ export async function worktree(ctx) {
|
|
|
284
311
|
[...invocation.args, script, ...worktreeEngineArgs(parsed, ctx.target, source)],
|
|
285
312
|
{
|
|
286
313
|
cwd: source,
|
|
287
|
-
env:
|
|
314
|
+
env: {
|
|
315
|
+
...process.env,
|
|
316
|
+
FLOWER_WORKTREE_NODE: process.execPath,
|
|
317
|
+
FLOWER_WORKTREE_HELPER: fileURLToPath(new URL("../lib/worktree-flower-state.js", import.meta.url)),
|
|
318
|
+
},
|
|
288
319
|
encoding: "utf8",
|
|
289
320
|
windowsHide: true,
|
|
290
321
|
},
|
|
@@ -6,7 +6,7 @@ import { formatPatchDiagnostic } from "./patch-conflicts.js";
|
|
|
6
6
|
* 叠加强化包的兼容 facade。
|
|
7
7
|
*
|
|
8
8
|
* 成功状态统一由 Plugin Runtime 写入 `.flower/`;旧
|
|
9
|
-
* `.trellis/.flower-manifest.json`
|
|
9
|
+
* `.trellis/.flower-manifest.json` 作为迁移证据读取,迁移成功后由同一事务删除。
|
|
10
10
|
*
|
|
11
11
|
* @param {string} target 目标项目根
|
|
12
12
|
* @param {{variant?:string|null,skills?:string[],dryRun?:boolean}} [opts] 兼容参数
|
package/src/lib/manifest.js
CHANGED
|
@@ -4,13 +4,7 @@ import crypto from "node:crypto";
|
|
|
4
4
|
import { ProjectStore } from "../plugin/state/project-store.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 记录「flower 上一次为该项目铺设了哪些强化文件」,使升级(如 0.5/old → 0.6)时
|
|
10
|
-
* 能精确删除当前变体不再包含的过期 skill / command —— 只删自己铺过的路径,
|
|
11
|
-
* 绝不误删用户或 Trellis 本体的文件。
|
|
12
|
-
*
|
|
13
|
-
* 放在 .trellis/ 下,随项目的 Trellis 生命周期存在(uninstall 删 .trellis 时一并消失)。
|
|
7
|
+
* 旧安装清单的兼容读取入口;成功迁移后删除,现代状态由 ProjectStore 管理。
|
|
14
8
|
*/
|
|
15
9
|
const MANIFEST_REL = path.join(".trellis", ".flower-manifest.json");
|
|
16
10
|
const LEGACY_UPDATE_CHECK_CACHE_REL = path.join(".trellis", ".flower-update-check.tmp");
|
|
@@ -148,7 +142,7 @@ function normalizeLastRemote(value) {
|
|
|
148
142
|
* @param {object|null|undefined} value 原始 updateCheck 字段
|
|
149
143
|
* @returns {{enabled:boolean,policy:string,intervalHours:number}} 归一化后的策略
|
|
150
144
|
*/
|
|
151
|
-
function normalizeUpdateCheckPolicy(value) {
|
|
145
|
+
export function normalizeUpdateCheckPolicy(value) {
|
|
152
146
|
const raw = value && typeof value === "object" ? value : {};
|
|
153
147
|
const policy = UPDATE_POLICIES.has(raw.policy) ? raw.policy : DEFAULT_UPDATE_CHECK.policy;
|
|
154
148
|
const interval = Number(raw.intervalHours);
|
|
@@ -374,18 +368,51 @@ export function readManifest(target) {
|
|
|
374
368
|
* @returns {{status:"missing"|"valid"|"corrupt",manifest:object|null,error?:Error}} 迁移证据
|
|
375
369
|
*/
|
|
376
370
|
export function readLegacyManifestStatus(target) {
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
371
|
+
const result = readJsonFileStatus(manifestPath(target));
|
|
372
|
+
if (result.status === "valid" && !isPlainObject(result.value)) {
|
|
373
|
+
return { status: "corrupt", manifest: null, error: new Error("旧 manifest 必须是对象") };
|
|
374
|
+
}
|
|
375
|
+
return { status: result.status, manifest: result.value, ...(result.error ? { error: result.error } : {}) };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 规划旧清单退出所需的配置写入及文件删除,保持 preflight 只读。
|
|
380
|
+
*
|
|
381
|
+
* @param {string} target 项目根
|
|
382
|
+
* @returns {Array<{path:string,content:Buffer|null}>} 固定目标及内容,null 表示删除
|
|
383
|
+
*/
|
|
384
|
+
export function planLegacyManifestMigration(target) {
|
|
385
|
+
const legacy = readLegacyManifestStatus(target);
|
|
386
|
+
if (legacy.status === "missing") return [];
|
|
387
|
+
if (legacy.status === "corrupt") throw new Error(`旧 flower manifest 损坏,拒绝删除:${manifestPath(target)}`, { cause: legacy.error });
|
|
388
|
+
const writes = [];
|
|
389
|
+
const settings = readSettingsStatus(target);
|
|
390
|
+
if (settings.status === "corrupt") throw new Error(`update-check settings 损坏,拒绝迁移:${settingsPath(target)}`, { cause: settings.error });
|
|
391
|
+
const updateCheck = legacy.manifest.updateCheck;
|
|
392
|
+
if (settings.status === "missing" && hasAnyOwn(updateCheck, UPDATE_CHECK_POLICY_KEYS)) {
|
|
393
|
+
writes.push({
|
|
394
|
+
path: ".flower/settings.json",
|
|
395
|
+
content: Buffer.from(JSON.stringify({ schemaVersion: 1, updateCheck: normalizeUpdateCheckPolicy(updateCheck) }, null, 2) + "\n"),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const cache = readJsonFileStatus(updateCheckCachePath(target));
|
|
399
|
+
if (cache.status === "corrupt" || (cache.status === "valid" && !isPlainObject(cache.value))) {
|
|
400
|
+
throw new Error(`update-check cache 损坏,拒绝迁移:${updateCheckCachePath(target)}`);
|
|
401
|
+
}
|
|
402
|
+
if (cache.status === "missing") {
|
|
403
|
+
const oldCache = readJsonFileStatus(path.join(target, LEGACY_UPDATE_CHECK_CACHE_REL));
|
|
404
|
+
if (oldCache.status === "corrupt" || (oldCache.status === "valid" && !isPlainObject(oldCache.value))) {
|
|
405
|
+
throw new Error("旧 update-check cache 损坏,拒绝丢失迁移证据");
|
|
406
|
+
}
|
|
407
|
+
if (oldCache.status === "valid" || hasLegacyUpdateCheckCache(updateCheck)) {
|
|
408
|
+
writes.push({
|
|
409
|
+
path: ".flower/update-check.tmp",
|
|
410
|
+
content: Buffer.from(JSON.stringify(normalizeUpdateCheckCache(oldCache.value || updateCheck), null, 2) + "\n"),
|
|
411
|
+
});
|
|
383
412
|
}
|
|
384
|
-
return { status: "valid", manifest };
|
|
385
|
-
} catch (error) {
|
|
386
|
-
if (error?.code === "ENOENT") return { status: "missing", manifest: null };
|
|
387
|
-
return { status: "corrupt", manifest: null, error };
|
|
388
413
|
}
|
|
414
|
+
writes.push({ path: ".trellis/.flower-manifest.json", content: null });
|
|
415
|
+
return writes;
|
|
389
416
|
}
|
|
390
417
|
|
|
391
418
|
/**
|
|
@@ -406,7 +433,7 @@ export function normalizeUpdateCheck(value) {
|
|
|
406
433
|
/**
|
|
407
434
|
* 读取启动更新检查配置。
|
|
408
435
|
*
|
|
409
|
-
*
|
|
436
|
+
* 返回现代 settings 策略与 tmp 缓存的合并视图,尚未迁移时兼容旧记录。
|
|
410
437
|
*
|
|
411
438
|
* @param {string} target 目标项目根
|
|
412
439
|
* @returns {ReturnType<typeof normalizeUpdateCheck>} 归一化后的配置
|
|
@@ -464,27 +491,3 @@ export function writeUpdateCheck(target, patch) {
|
|
|
464
491
|
}
|
|
465
492
|
return readUpdateCheck(target);
|
|
466
493
|
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* 写入 manifest。
|
|
470
|
-
*
|
|
471
|
-
* 若调用方没有显式传入 `updateCheck`,保留目标项目已有策略,避免全装重写时覆盖用户选择。
|
|
472
|
-
* 运行缓存会迁移到 `.flower-update-check.tmp`,不得继续写入 manifest。
|
|
473
|
-
*
|
|
474
|
-
* @param {string} target 目标项目根
|
|
475
|
-
* @param {object} data manifest 新内容
|
|
476
|
-
*/
|
|
477
|
-
export function writeManifest(target, data) {
|
|
478
|
-
const current = readManifest(target);
|
|
479
|
-
const next = { ...data };
|
|
480
|
-
if (hasLegacyUpdateCheckCache(current?.updateCheck)) {
|
|
481
|
-
migrateLegacyUpdateCheckCache(target, current.updateCheck);
|
|
482
|
-
}
|
|
483
|
-
if (Object.prototype.hasOwnProperty.call(data, "updateCheck")) {
|
|
484
|
-
migrateLegacyUpdateCheckCache(target, data.updateCheck);
|
|
485
|
-
next.updateCheck = normalizeUpdateCheckPolicy(data.updateCheck);
|
|
486
|
-
} else {
|
|
487
|
-
next.updateCheck = normalizeUpdateCheckPolicy(current?.updateCheck);
|
|
488
|
-
}
|
|
489
|
-
fs.writeFileSync(manifestPath(target), JSON.stringify(next, null, 2) + "\n");
|
|
490
|
-
}
|
package/src/lib/self-check.js
CHANGED
|
@@ -572,8 +572,9 @@ export async function buildSelfCheck(target, options = {}) {
|
|
|
572
572
|
const projectTrellis = readProjectTrellisVersion(absoluteTarget);
|
|
573
573
|
const skillGardenLock = pluginLock?.plugins.find(({ id }) => id === SKILL_GARDEN_PLUGIN_ID);
|
|
574
574
|
const projectFlower = skillGardenLock?.version || (
|
|
575
|
-
typeof manifest?.flowerVersion === "string" ? manifest.flowerVersion : null
|
|
575
|
+
typeof manifest?.flowerVersion === "string" && manifest.flowerVersion.trim() ? manifest.flowerVersion : null
|
|
576
576
|
);
|
|
577
|
+
const flowerVersionSource = skillGardenLock?.version ? "plugin-lock" : projectFlower ? "legacy-manifest" : null;
|
|
577
578
|
const projectOutOfSyncReasons = [];
|
|
578
579
|
if (projectFlower && projectFlower !== currentFlower) {
|
|
579
580
|
projectOutOfSyncReasons.push("flower_version_mismatch");
|
|
@@ -602,6 +603,8 @@ export async function buildSelfCheck(target, options = {}) {
|
|
|
602
603
|
},
|
|
603
604
|
project: {
|
|
604
605
|
flowerVersion: projectFlower,
|
|
606
|
+
flowerVersionStatus: projectFlower ? "known" : "unknown",
|
|
607
|
+
flowerVersionSource,
|
|
605
608
|
trellisVersion: projectTrellis,
|
|
606
609
|
manifestPresent: Boolean(manifest),
|
|
607
610
|
pluginStatePresent: Boolean(pluginLock && pluginState),
|
|
@@ -689,8 +692,8 @@ export async function buildSelfCheck(target, options = {}) {
|
|
|
689
692
|
}
|
|
690
693
|
return {
|
|
691
694
|
...base,
|
|
692
|
-
status: "skipped",
|
|
693
|
-
reason: "interval_not_elapsed",
|
|
695
|
+
status: projectFlower ? "skipped" : "project_unknown",
|
|
696
|
+
reason: projectFlower ? "interval_not_elapsed" : "project_flower_version_unknown",
|
|
694
697
|
remote: { ...base.remote, tags, fromCache: true, skipped: true },
|
|
695
698
|
};
|
|
696
699
|
}
|
|
@@ -744,7 +747,8 @@ export async function buildSelfCheck(target, options = {}) {
|
|
|
744
747
|
}
|
|
745
748
|
return {
|
|
746
749
|
...resultBase,
|
|
747
|
-
status: remoteStatus,
|
|
750
|
+
status: projectFlower ? remoteStatus : "project_unknown",
|
|
751
|
+
reason: projectFlower ? null : "project_flower_version_unknown",
|
|
748
752
|
remote: { ...resultBase.remote, tags },
|
|
749
753
|
};
|
|
750
754
|
}
|
|
@@ -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
|
+
}
|