create-yss-spec 3.5.4 → 3.5.5
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 +8 -0
- package/cli-core.lock.json +130 -0
- package/package.json +7 -3
- package/resources/upgrade-skill/SKILL.md +26 -0
- package/resources/upgrade-skill/protocol.md +40 -0
- package/resources/upgrade-skill/references/cli-families.md +14 -0
- package/src/cli/help.js +1 -0
- package/src/cli/index.js +12 -0
- package/src/filesystem/transaction-runner.js +27 -0
- package/src/template/asset-runtime.js +6 -0
- package/src/template/verification-runtime.js +9 -1
- package/template/.agents/skills/.strategic-design-skills-manifest.json +2 -2
- package/template/.agents/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.agents/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.agents/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.agents/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.agents/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.agents/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.agents/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.agents/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.codex/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.codex/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.codex/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.codex/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.codex/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.codex/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.codex/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.codex/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.cursor/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.cursor/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.cursor/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.cursor/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.cursor/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.cursor/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.cursor/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.cursor/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.pi/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.pi/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.pi/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.pi/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.pi/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.pi/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.pi/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.pi/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.template-spec/agents/yss-skill-registry.yaml +9 -1
- package/template/.template-spec/process/contract-reading.md +1 -1
- package/template/.template-spec/process/document-writing.md +12 -0
- package/template/.template-spec/process/harness-upgrade.md +40 -0
- package/template/.template-spec/process/lifecycle-artifact-map.md +2 -2
- package/template/.template-spec/process/lifecycle-registry.yaml +3 -1
- package/template/.template-spec/process/schemas/approval-record.schema.json +23 -0
- package/template/.template-spec/process/stage-tracking.md +2 -2
- package/template/.template-spec/process/structured-assets.md +35 -0
- package/template/.template-spec/process/templates/lifecycle-checkpoint-template.json +48 -0
- package/template/.template-spec/templates/approval-record-template.json +23 -0
- package/template/scripts/check-plan-stage-coverage +12 -0
- package/template/scripts/contract +6 -0
- package/template/scripts/lib/approval-record.mjs +4 -3
- package/template/scripts/lib/asset-command.mjs +35 -0
- package/template/scripts/lib/asset-migration.mjs +101 -0
- package/template/scripts/lib/asset-transactions.mjs +235 -0
- package/template/scripts/lib/checkpoint-boundary.mjs +3 -0
- package/template/scripts/lib/contract-views.mjs +7 -1
- package/template/scripts/lib/json-schema.mjs +43 -4
- package/template/scripts/lib/lifecycle-context-query.mjs +7 -0
- package/template/scripts/lib/lifecycle-presentation.mjs +77 -0
- package/template/scripts/lib/lifecycle-status.mjs +16 -1
- package/template/scripts/lib/plan-stage-coverage.mjs +40 -0
- package/template/scripts/lib/reading-view-bundle.mjs +1 -0
- package/template/scripts/lib/reading-view-markdown.mjs +8 -6
- package/template/scripts/lib/stage-tracking-migration.mjs +11 -3
- package/template/scripts/lib/structured-assets.mjs +81 -0
- package/template/scripts/lib/task-package.mjs +4 -1
- package/template/scripts/lifecycle-status +6 -3
- package/template/scripts/verify-lifecycle-checkpoint +8 -5
- package/template/skills-lock.json +21 -9
- package/template.manifest.json +2 -1
- package/template.snapshot.json +5 -5
- package/vendor/cli-core/build.mjs +302 -0
- package/vendor/cli-core/bundle.mjs +198 -0
- package/vendor/cli-core/cli.mjs +182 -0
- package/vendor/cli-core/command-runner.mjs +107 -0
- package/vendor/cli-core/engine.mjs +239 -0
- package/vendor/cli-core/family.mjs +28 -0
- package/vendor/cli-core/gitignore.mjs +15 -0
- package/vendor/cli-core/identity.mjs +274 -0
- package/vendor/cli-core/io.mjs +153 -0
- package/vendor/cli-core/layout-migration.mjs +64 -0
- package/vendor/cli-core/legacy.mjs +19 -0
- package/vendor/cli-core/migrate.mjs +237 -0
- package/vendor/cli-core/migration-adapter.mjs +19 -0
- package/vendor/cli-core/package.json +13 -0
- package/vendor/cli-core/scaffold.mjs +117 -0
- package/vendor/cli-core/spec-migration-adapter.mjs +83 -0
- package/vendor/cli-core/tests/build.test.mjs +40 -0
- package/vendor/cli-core/tests/cli.test.mjs +1065 -0
- package/vendor/cli-core/tests/migration-version.test.mjs +8 -0
- package/vendor/cli-core/tests/package-contract.mjs +75 -0
- package/vendor/cli-core/transaction.mjs +529 -0
- package/vendor/cli-core/update.mjs +79 -0
- package/vendor/cli-core/vendor/NOTICES.md +6 -0
- package/vendor/cli-core/vendor/yaml.LICENSE +13 -0
- package/vendor/cli-core/vendor/yaml.mjs +6311 -0
- package/vendor/cli-core/verification.mjs +131 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { appendFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
function killTree(child, signal) {
|
|
7
|
+
if (!child.pid) return;
|
|
8
|
+
try {
|
|
9
|
+
if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
10
|
+
else process.kill(-child.pid, signal);
|
|
11
|
+
} catch (error) { if (error.code !== "ESRCH") throw error; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Bounded lifetime for external tools; stdout is never mixed with progress. */
|
|
15
|
+
export function runCommand(command, args, { cwd, env = process.env, timeoutMs = 0, signal, stdoutFile, stderrFile, secrets = [], progress = false } = {}) {
|
|
16
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new TypeError("timeoutMs 必须是非负毫秒数");
|
|
17
|
+
if (signal?.aborted) return Promise.resolve({ status: 1, stdout: "", stderr: "command cancelled", termination: "cancelled", duration_ms: 0 });
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
const started = performance.now();
|
|
20
|
+
const argumentSecrets = args.flatMap(value => { try { const url = new URL(value); return url.password ? [url.password, decodeURIComponent(url.password)] : []; } catch { return []; } });
|
|
21
|
+
const secretNames = new Map(Object.entries(env).filter(([key]) => /(?:PASSWORD|TOKEN|SECRET)$/.test(key) || key === "MAVEN_REPO_USERNAME").map(([key,value]) => [value,key]));
|
|
22
|
+
const replacement = secret => secretNames.has(secret) ? `[REDACTED:${secretNames.get(secret)}]` : "[REDACTED]";
|
|
23
|
+
const sensitive = [...secrets, ...argumentSecrets, ...secretNames.keys()].filter(value => typeof value === "string" && value.length > 0).sort((a, b) => b.length - a.length);
|
|
24
|
+
const redact = text => sensitive.reduce((value, secret) => value.replaceAll(secret, replacement(secret)), text);
|
|
25
|
+
if (progress) process.stderr.write(`[开始] ${redact(command + " " + args.join(" "))}\n`);
|
|
26
|
+
for (const file of [stdoutFile, stderrFile]) if (file) writeFileSync(file, "");
|
|
27
|
+
const child = spawn(command, args, { cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
|
|
28
|
+
let termination = null, escalation, timer, errorMessage;
|
|
29
|
+
const makeStream = file => {
|
|
30
|
+
const decoder = new StringDecoder("utf8");
|
|
31
|
+
let pending = "", captured = "";
|
|
32
|
+
const emit = text => { captured += text; if (file) appendFileSync(file, text); };
|
|
33
|
+
function consume(final = false) {
|
|
34
|
+
let output = "", cursor = 0;
|
|
35
|
+
while (cursor < pending.length) {
|
|
36
|
+
const match = sensitive.find(secret => pending.startsWith(secret, cursor));
|
|
37
|
+
if (match) { output += replacement(match); cursor += match.length; continue; }
|
|
38
|
+
if (!final && sensitive.some(secret => secret.startsWith(pending.slice(cursor)))) break;
|
|
39
|
+
output += pending[cursor++];
|
|
40
|
+
}
|
|
41
|
+
pending = pending.slice(cursor);
|
|
42
|
+
emit(output);
|
|
43
|
+
}
|
|
44
|
+
return { write(bytes) { pending += decoder.write(bytes); consume(); }, end() { pending += decoder.end(); consume(true); return captured; } };
|
|
45
|
+
};
|
|
46
|
+
const stdout = makeStream(stdoutFile), stderr = makeStream(stderrFile);
|
|
47
|
+
function cancel(reason) {
|
|
48
|
+
if (termination) return;
|
|
49
|
+
termination = reason;
|
|
50
|
+
killTree(child, "SIGTERM");
|
|
51
|
+
escalation = setTimeout(() => killTree(child, "SIGKILL"), 1000);
|
|
52
|
+
}
|
|
53
|
+
const abort = () => cancel("cancelled");
|
|
54
|
+
const onInterrupt = () => cancel("cancelled");
|
|
55
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
56
|
+
process.on("SIGINT", onInterrupt); process.on("SIGTERM", onInterrupt);
|
|
57
|
+
if (timeoutMs) timer = setTimeout(() => cancel("timeout"), timeoutMs);
|
|
58
|
+
child.stdout.on("data", bytes => stdout.write(bytes));
|
|
59
|
+
child.stderr.on("data", bytes => stderr.write(bytes));
|
|
60
|
+
child.on("error", error => { errorMessage = redact(error.message); });
|
|
61
|
+
child.on("close", (code, exitSignal) => {
|
|
62
|
+
clearTimeout(timer); clearTimeout(escalation);
|
|
63
|
+
// A group leader can exit before its descendants close redirected streams.
|
|
64
|
+
if (termination) killTree(child, "SIGKILL");
|
|
65
|
+
signal?.removeEventListener("abort", abort);
|
|
66
|
+
process.off("SIGINT", onInterrupt); process.off("SIGTERM", onInterrupt);
|
|
67
|
+
const duration_ms = performance.now() - started;
|
|
68
|
+
const status = termination === "timeout" ? 124 : termination ? 1 : code ?? 1;
|
|
69
|
+
if (progress) process.stderr.write(`[${status === 0 ? "完成" : "失败"}] ${command} (${Math.round(duration_ms)}ms, exit=${status}${termination ? `, ${termination}` : ""})\n`);
|
|
70
|
+
resolve({ status, stdout: stdout.end(), stderr: stderr.end() + (errorMessage || ""), signal: exitSignal, termination, duration_ms });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Synchronous API compatibility for existing generators/cache consumers. The worker
|
|
76
|
+
// owns the process group and streams logs; it never updates project metadata.
|
|
77
|
+
export function runCommandSync(command, args, options = {}) {
|
|
78
|
+
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), "--worker"], {
|
|
79
|
+
input: JSON.stringify({ command, args, options }), encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
|
|
80
|
+
stdio: ["pipe", "pipe", "inherit"], env: options.env ?? process.env,
|
|
81
|
+
});
|
|
82
|
+
if (result.error || result.status !== 0) throw new TypeError(result.error?.message || "command worker failed");
|
|
83
|
+
return JSON.parse(result.stdout);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === "--worker") {
|
|
87
|
+
const parent = process.ppid;
|
|
88
|
+
let input = "";
|
|
89
|
+
for await (const bytes of process.stdin) input += bytes;
|
|
90
|
+
const { command, args, options } = JSON.parse(input);
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const watchParent = setInterval(() => {
|
|
93
|
+
if (process.ppid !== parent) controller.abort();
|
|
94
|
+
else {
|
|
95
|
+
try { process.kill(parent, 0); } catch (error) { if (error.code === "ESRCH") controller.abort(); }
|
|
96
|
+
}
|
|
97
|
+
}, 100);
|
|
98
|
+
try {
|
|
99
|
+
const result = await runCommand(command, args, { ...options, signal: controller.signal });
|
|
100
|
+
if (result.status !== 0) {
|
|
101
|
+
// Only caller-created temporary resources are supplied here. The worker
|
|
102
|
+
// also owns this cleanup when a terminated synchronous caller cannot run finally.
|
|
103
|
+
for (const directory of options.cleanupPathsOnFailure || []) rmSync(directory, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
if (process.ppid === parent) process.stdout.write(JSON.stringify(result));
|
|
106
|
+
} finally { clearInterval(watchParent); }
|
|
107
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { ensure, stat, safe, descriptor, same, hash, json } from "./io.mjs";
|
|
5
|
+
import {
|
|
6
|
+
identity,
|
|
7
|
+
guardNestedRepository,
|
|
8
|
+
METADATA,
|
|
9
|
+
gitlinks,
|
|
10
|
+
recoveryIdentity,
|
|
11
|
+
} from "./identity.mjs";
|
|
12
|
+
import { preserved, userOwned } from "./family.mjs";
|
|
13
|
+
import { mergeGitignore } from "./gitignore.mjs";
|
|
14
|
+
import { prepareGenerated, verifyInstance } from "./verification.mjs";
|
|
15
|
+
import { PROFILE, render } from "./bundle.mjs";
|
|
16
|
+
import { inspectState, applyTransaction, recover } from "./transaction.mjs";
|
|
17
|
+
import { prepareLayoutMigration } from "./layout-migration.mjs";
|
|
18
|
+
export function execute(bundle, target, opts) {
|
|
19
|
+
const { family: f, pkg, snapshot, core } = bundle;
|
|
20
|
+
const state = inspectState(target, f);
|
|
21
|
+
if (state.pending.length) {
|
|
22
|
+
for (const ref of METADATA.filter((x) => x !== f.metadataFile))
|
|
23
|
+
ensure(
|
|
24
|
+
!stat(safe(target, ref)),
|
|
25
|
+
"异族 metadata 禁止事务恢复",
|
|
26
|
+
"IDENTITY",
|
|
27
|
+
);
|
|
28
|
+
ensure(
|
|
29
|
+
opts.apply && !opts.dryRun && !opts.plan && opts.command !== "diff",
|
|
30
|
+
"发现中断事务;使用 --apply 恢复后重试",
|
|
31
|
+
"INTERRUPTED",
|
|
32
|
+
);
|
|
33
|
+
recoveryIdentity(target, bundle, state);
|
|
34
|
+
return recover(target, f, state, () =>
|
|
35
|
+
recoveryIdentity(target, bundle, state),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (opts.command === "init")
|
|
39
|
+
ensure(
|
|
40
|
+
!stat(target) || fs.readdirSync(target).length === 0,
|
|
41
|
+
"init 只允许不存在或空目录;已有项目请使用 attach",
|
|
42
|
+
);
|
|
43
|
+
else ensure(stat(target)?.isDirectory(), "attach/sync 目标目录不存在");
|
|
44
|
+
const meta = identity(target, bundle, opts.command, opts.migrateLayout);
|
|
45
|
+
const canForce = f.side !== "design" && opts.force && opts.apply;
|
|
46
|
+
const variables = meta?.variables || {
|
|
47
|
+
projectName: opts.projectName || path.basename(target),
|
|
48
|
+
businessDomain: opts.businessDomain,
|
|
49
|
+
teamSize: opts.teamSize,
|
|
50
|
+
issueTracker: opts.issueTracker,
|
|
51
|
+
includeExampleDocs: opts.includeExampleDocs,
|
|
52
|
+
};
|
|
53
|
+
const protectedLinks = gitlinks(target);
|
|
54
|
+
const files = render(bundle, variables);
|
|
55
|
+
const layout = prepareLayoutMigration(target, meta, files, opts);
|
|
56
|
+
for (const [ref, bytes] of layout.replacements) {
|
|
57
|
+
files.set(ref, {bytes, baseline:{type:'file',digest:hash(bytes),mode:0o644}});
|
|
58
|
+
}
|
|
59
|
+
const legacyRefs = new Set(layout.legacyFiles);
|
|
60
|
+
const
|
|
61
|
+
managedFiles = {},
|
|
62
|
+
operations = [],
|
|
63
|
+
changes = [],
|
|
64
|
+
conflicts = [],
|
|
65
|
+
observed = new Map();
|
|
66
|
+
for (const ref of [
|
|
67
|
+
...new Set([...files.keys(), ...Object.keys(meta?.managedFiles || {}), ...Object.keys(bundle.retiredFiles || {})]),
|
|
68
|
+
].sort()) {
|
|
69
|
+
if (legacyRefs.has(ref)) continue;
|
|
70
|
+
ensure(
|
|
71
|
+
!protectedLinks.some((x) => ref === x || ref.startsWith(x + "/")),
|
|
72
|
+
`受保护 gitlink: ${ref}`,
|
|
73
|
+
"PROTECTED",
|
|
74
|
+
);
|
|
75
|
+
guardNestedRepository(target, ref);
|
|
76
|
+
const before = descriptor(target, ref);
|
|
77
|
+
let next = files.get(ref);
|
|
78
|
+
if (ref === '.gitignore' && next) {
|
|
79
|
+
const bytes = mergeGitignore(before ? fs.readFileSync(safe(target, ref)) : null, next.bytes, f);
|
|
80
|
+
next = {bytes, baseline:{type:'file',digest:hash(bytes),mode:before?.mode || 0o644}};
|
|
81
|
+
}
|
|
82
|
+
const after = next?.baseline || null, record = meta?.managedFiles[ref], retiredBaseline = bundle.retiredFiles?.[ref], transitionBaseline = meta ? bundle.transitionBaselines?.[ref] : null, baseline = record?.baseline || (transitionBaseline ? {type:'file',digest:transitionBaseline.digest,mode:transitionBaseline.mode} : null) || (retiredBaseline ? {type:'file',digest:retiredBaseline.digest,mode:retiredBaseline.mode} : null);
|
|
83
|
+
let action, reason;
|
|
84
|
+
observed.set(ref, before);
|
|
85
|
+
if (userOwned(ref, f)) {
|
|
86
|
+
action = before ? 'preserve' : (next && opts.command !== 'sync' && opts.command !== 'diff' ? 'add' : 'preserve');
|
|
87
|
+
reason = 'user-owned';
|
|
88
|
+
} else if (preserved(ref) && before) { action='preserve';reason='project-owned-content'; }
|
|
89
|
+
else if (!next) {
|
|
90
|
+
const retiredRecord = record || (retiredBaseline ? {baseline,lastApplied:null,ownership:'managed',retired:true} : null);
|
|
91
|
+
const eligible = retiredRecord && !retiredRecord.legacyUnproven && ref !== '.gitignore' && same(before, baseline) && before;
|
|
92
|
+
action = eligible && opts.prune ? 'delete' : 'preserve';
|
|
93
|
+
reason = eligible ? (opts.prune ? 'prune' : 'prunable') : 'retained-removed';
|
|
94
|
+
if (before && action !== 'delete' && retiredRecord) managedFiles[ref] = {...retiredRecord,retired:true};
|
|
95
|
+
} else if (same(before, after)) action='unchanged';
|
|
96
|
+
else if (ref === '.gitignore' || ref === 'skills-lock.json') action=before?'update':'add';
|
|
97
|
+
else if (!before || same(before, baseline) || (record?.legacyUnproven && before?.digest === baseline?.digest)) action=before?'update':'add';
|
|
98
|
+
else if (!record?.legacyUnproven && same(after, baseline)) action='preserve';
|
|
99
|
+
else action='conflict';
|
|
100
|
+
const resolution = opts.migrationResolutions?.[ref];
|
|
101
|
+
let merged;
|
|
102
|
+
if (resolution) {
|
|
103
|
+
ensure(action === 'conflict' && baseline && !record?.legacyUnproven, `不能合并非可信冲突: ${ref}`, 'CONFLICT');
|
|
104
|
+
ensure(resolution.beforeDigest === before?.digest && resolution.templateDigest === after?.digest, `冲突决议已过期: ${ref}`, 'CONCURRENT');
|
|
105
|
+
ensure(['preserve','merge'].includes(resolution.action), `未知冲突决议: ${ref}`);
|
|
106
|
+
if (resolution.action === 'preserve') action = 'preserve';
|
|
107
|
+
else {
|
|
108
|
+
ensure(typeof resolution.contentBase64 === 'string', `缺少合并内容: ${ref}`);
|
|
109
|
+
merged = Buffer.from(resolution.contentBase64, 'base64');
|
|
110
|
+
action = 'update';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (action === 'conflict') conflicts.push(ref);
|
|
114
|
+
if (next && !userOwned(ref,f)) managedFiles[ref] = {
|
|
115
|
+
baseline:after,lastApplied:action==='preserve'?before:merged?{type:'file',digest:hash(merged),mode:before.mode}:after,ownership:ref==='skills-lock.json'?'generated':'managed',
|
|
116
|
+
...(record?.legacyUnproven && action==='preserve' ? {legacyUnproven:true} : {}),
|
|
117
|
+
};
|
|
118
|
+
if (['update','add','delete'].includes(action) || (action==='conflict' && canForce)) operations.push({path:ref,before,after:merged?{type:'file',digest:hash(merged),mode:before.mode}:after,bytes:merged || next?.bytes});
|
|
119
|
+
changes.push({path:ref,action,...(reason?{reason}:{})});
|
|
120
|
+
}
|
|
121
|
+
for (const ref of Object.keys(opts.migrationResolutions || {})) ensure(changes.some(c=>c.path===ref) && !conflicts.includes(ref), `未消费的冲突决议: ${ref}`, 'CONFLICT');
|
|
122
|
+
for (const operation of layout.deletions) {
|
|
123
|
+
guardNestedRepository(target, operation.path);
|
|
124
|
+
ensure(!protectedLinks.some((x) => operation.path === x || operation.path.startsWith(x + "/")), `受保护 gitlink: ${operation.path}`, "PROTECTED");
|
|
125
|
+
observed.set(operation.path, operation.before);
|
|
126
|
+
operations.push(operation);
|
|
127
|
+
changes.push({path:operation.path,action:'delete',reason:'migrate-layout'});
|
|
128
|
+
}
|
|
129
|
+
const result = {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
command: opts.command,
|
|
132
|
+
status: "preview",
|
|
133
|
+
target,
|
|
134
|
+
cliVersion: pkg.version,
|
|
135
|
+
templateCommit: snapshot.templateCommit,
|
|
136
|
+
templateSourceState: snapshot.sourceState || "committed",
|
|
137
|
+
coreSourceState: core.sourceState || "committed",
|
|
138
|
+
coreVersion: core.coreVersion,
|
|
139
|
+
changes,
|
|
140
|
+
conflicts,
|
|
141
|
+
conflictDetails: conflicts.map(ref=>({path:ref,beforeDigest:observed.get(ref)?.digest,templateDigest:files.get(ref)?.baseline?.digest,resolutionSupported:Boolean(meta?.managedFiles?.[ref]?.baseline && !meta.managedFiles[ref].legacyUnproven)})),
|
|
142
|
+
backupPath: null,
|
|
143
|
+
};
|
|
144
|
+
if (conflicts.length && !canForce)
|
|
145
|
+
throw Object.assign(
|
|
146
|
+
new Error(
|
|
147
|
+
`治理文件冲突,整次暂停;确认备份覆盖后使用 --apply --force: ${conflicts.join(", ")}`,
|
|
148
|
+
),
|
|
149
|
+
{ code: "CONFLICT", result },
|
|
150
|
+
);
|
|
151
|
+
result.summary = Object.fromEntries(['add','update','delete','preserve','conflict','unchanged'].map(action=>[action,changes.filter(c=>c.action===action).length]));
|
|
152
|
+
result.prunable = changes.filter(c=>['prunable','prune'].includes(c.reason)).map(c=>c.path);
|
|
153
|
+
result.retainedRemoved = changes.filter(c=>c.reason==='retained-removed').map(c=>c.path);
|
|
154
|
+
result.pruned = [];
|
|
155
|
+
result.migrationRequired = Boolean(meta?.legacy || layout.legacyFiles.length);
|
|
156
|
+
result.legacyRetainedFiles = meta?.legacyRetainedFiles || [];
|
|
157
|
+
if (opts.dryRun || opts.plan || opts.command === 'diff' || (opts.command !== "init" && !opts.apply)) return result;
|
|
158
|
+
const watched = [...METADATA, "yss-project.yaml", PROFILE, ".gitmodules"];
|
|
159
|
+
const beforeIdentity = new Map(
|
|
160
|
+
watched.map((ref) => [ref, descriptor(target, ref)]),
|
|
161
|
+
);
|
|
162
|
+
const transactionId = opts.migrationId || randomUUID();
|
|
163
|
+
const metadata = {
|
|
164
|
+
lastTransactionId: transactionId,
|
|
165
|
+
metadataSchemaVersion: 2,
|
|
166
|
+
profileId: f.profileId,
|
|
167
|
+
templateName: f.templateName,
|
|
168
|
+
templateSource: f.templateSource,
|
|
169
|
+
cliVersion: pkg.version,
|
|
170
|
+
templateCommit: snapshot.templateCommit,
|
|
171
|
+
templateSourceState: snapshot.sourceState || "committed",
|
|
172
|
+
coreSourceState: core.sourceState || "committed",
|
|
173
|
+
snapshotHash: snapshot.snapshotHash,
|
|
174
|
+
coreVersion: core.coreVersion,
|
|
175
|
+
coreDigest: core.digest,
|
|
176
|
+
manifestHash: snapshot.manifestHash,
|
|
177
|
+
variables,
|
|
178
|
+
managedFiles,
|
|
179
|
+
...(meta?.legacyRetainedFiles?.length ? {legacyRetainedFiles:meta.legacyRetainedFiles} : {}),
|
|
180
|
+
baselineDigest: hash(JSON.stringify(managedFiles)),
|
|
181
|
+
initializedAt: meta?.initializedAt || new Date().toISOString(),
|
|
182
|
+
lastSyncedAt: opts.migrationTime || new Date().toISOString(),
|
|
183
|
+
};
|
|
184
|
+
prepareGenerated(bundle, target, operations, metadata, observed);
|
|
185
|
+
if (!operations.length && meta && !meta.legacy && meta.snapshotHash === snapshot.snapshotHash && meta.coreDigest === core.digest && meta.cliVersion === pkg.version) return opts.captureMigration ? opts.captureMigration([], result) : {...result,status:'applied',message:'受管资产已是当前版本,无需写入'};
|
|
186
|
+
metadata.baselineDigest = hash(JSON.stringify(metadata.managedFiles));
|
|
187
|
+
const bytes = Buffer.from(json(metadata));
|
|
188
|
+
operations.push({
|
|
189
|
+
path: f.metadataFile,
|
|
190
|
+
before: descriptor(target, f.metadataFile),
|
|
191
|
+
after: { type: "file", digest: hash(bytes), mode: 0o644 },
|
|
192
|
+
bytes,
|
|
193
|
+
});
|
|
194
|
+
if (opts.captureMigration) return opts.captureMigration(operations, result);
|
|
195
|
+
// Metadata is last. Identity files already applied by this transaction have their new expected digest.
|
|
196
|
+
const opMap = new Map(operations.map((x) => [x.path, x]));
|
|
197
|
+
const validate = (operation) => {
|
|
198
|
+
if (!operation) {
|
|
199
|
+
ensure(
|
|
200
|
+
JSON.stringify(gitlinks(target)) === JSON.stringify(protectedLinks),
|
|
201
|
+
"gitlink 在计划后变化",
|
|
202
|
+
"CONCURRENT",
|
|
203
|
+
);
|
|
204
|
+
for (const [ref, before] of observed) {
|
|
205
|
+
const op = opMap.get(ref);
|
|
206
|
+
if (!op)
|
|
207
|
+
ensure(
|
|
208
|
+
same(descriptor(target, ref), before),
|
|
209
|
+
`未写入的受管文件并发变化: ${ref}`,
|
|
210
|
+
"CONCURRENT",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const [ref, before] of beforeIdentity) {
|
|
215
|
+
const op = opMap.get(ref),
|
|
216
|
+
current = descriptor(target, ref);
|
|
217
|
+
ensure(
|
|
218
|
+
same(current, before) || (op && same(current, op.after)),
|
|
219
|
+
`身份文件并发变化: ${ref}`,
|
|
220
|
+
"CONCURRENT",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (operation) guardNestedRepository(target, operation.path);
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
...result,
|
|
227
|
+
status: "applied",
|
|
228
|
+
...applyTransaction(
|
|
229
|
+
target,
|
|
230
|
+
f,
|
|
231
|
+
operations,
|
|
232
|
+
validate,
|
|
233
|
+
transactionId,
|
|
234
|
+
opts.gitInit,
|
|
235
|
+
() => verifyInstance(bundle, target),
|
|
236
|
+
),
|
|
237
|
+
pruned: changes.filter(c=>c.action==='delete').map(c=>c.path),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ensure } from './io.mjs';
|
|
2
|
+
export const FAMILIES = Object.freeze(Object.fromEntries(['design', 'backend', 'frontend'].map(side => [side, Object.freeze({
|
|
3
|
+
side,
|
|
4
|
+
packageName: `create-yss-harness-${side}`,
|
|
5
|
+
profileId: side === 'design' ? 'harness.business-ddd-strategy-handoff' : `harness.${side}-delivery`,
|
|
6
|
+
metadataFile: `.yss-harness-${side}.json`,
|
|
7
|
+
templateName: `yss-harness-${side}-agent`,
|
|
8
|
+
templateSource: `github:iloveZzz/yss-harness-${side}-agent`,
|
|
9
|
+
})])));
|
|
10
|
+
export function familyFor(side) {
|
|
11
|
+
ensure(Object.hasOwn(FAMILIES, side), '未知 CLI 家族', 'IDENTITY');
|
|
12
|
+
return FAMILIES[side];
|
|
13
|
+
}
|
|
14
|
+
export function preserved(ref) { return ['README.md', 'CONTEXT.md', 'DESIGN.md', '.template-spec/agents/issue-tracker.md'].includes(ref); }
|
|
15
|
+
export function userOwned(ref, family) {
|
|
16
|
+
if (['README.md', 'DESIGN.md'].includes(ref)) return true;
|
|
17
|
+
if (ref === 'CONTEXT.md') return false;
|
|
18
|
+
// Only reusable governance assets are eligible for recurring ownership.
|
|
19
|
+
if (family.side === 'design') return !(/^(?:\.(?:agents|claude|codex|cursor|pi|qoder|trae)\/skills\/|scripts\/|\.template-spec\/(?:agents\/|process\/|templates\/|user-guide\/|architecture\/templates\/))/.test(ref)
|
|
20
|
+
|| ['.gitignore', '.nvmrc', 'AGENTS.md', 'CLAUDE.md', '.cursorrules', 'skills-lock.json', 'yss-project.yaml'].includes(ref));
|
|
21
|
+
return /^(?:docs\/(?:\.scratch|reviews|releases|requirements|implementation|api|adr)(?:\/|$))/.test(ref);
|
|
22
|
+
}
|
|
23
|
+
export const CHECKS = [
|
|
24
|
+
['layout', 'scripts/verify-governance-layout'],
|
|
25
|
+
['context', 'scripts/verify-context-contract', '--root', '.', '--json'],
|
|
26
|
+
['profile', 'scripts/verify-harness-profile'],
|
|
27
|
+
['skill-lock', 'scripts/update-skill-lock', '--check'],
|
|
28
|
+
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ensure } from './io.mjs';
|
|
2
|
+
export function mergeGitignore(current, template, family) {
|
|
3
|
+
const start = `# >>> ${family.packageName} managed rules`;
|
|
4
|
+
const end = `# <<< ${family.packageName} managed rules`;
|
|
5
|
+
const body = Buffer.from(`${start}\n${template.toString().replace(/\s*$/, '')}\n.yss-harness-state/\n${end}\n`);
|
|
6
|
+
const before = current || Buffer.alloc(0);
|
|
7
|
+
const a = before.indexOf(start), b = before.indexOf(end);
|
|
8
|
+
ensure((a === -1 && b === -1) || (a >= 0 && b > a && before.indexOf(start, a + 1) === -1 && before.indexOf(end, b + 1) === -1), '.gitignore 受管标记损坏或重复', 'GITIGNORE');
|
|
9
|
+
if (a === -1) return Buffer.concat([before, before.length && before.at(-1) !== 10 ? Buffer.from('\n') : Buffer.alloc(0), body]);
|
|
10
|
+
ensure((a === 0 || before[a - 1] === 10) && [undefined, 10, 13].includes(before[b + Buffer.byteLength(end)]), '.gitignore 标记必须独占一行', 'GITIGNORE');
|
|
11
|
+
let tail = b + Buffer.byteLength(end);
|
|
12
|
+
if (before[tail] === 13) tail++;
|
|
13
|
+
if (before[tail] === 10) tail++;
|
|
14
|
+
return Buffer.concat([before.subarray(0, a), body, before.subarray(tail)]);
|
|
15
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
ensure,
|
|
6
|
+
safe,
|
|
7
|
+
stat,
|
|
8
|
+
readJson,
|
|
9
|
+
governance,
|
|
10
|
+
hash,
|
|
11
|
+
relative,
|
|
12
|
+
descriptor,
|
|
13
|
+
same,
|
|
14
|
+
} from "./io.mjs";
|
|
15
|
+
import { yaml, PROFILE } from "./bundle.mjs";
|
|
16
|
+
import { normalizeLegacy } from './legacy.mjs';
|
|
17
|
+
export const METADATA = [
|
|
18
|
+
".yss-template.json",
|
|
19
|
+
".yss-harness-design.json",
|
|
20
|
+
".yss-harness-dev.json",
|
|
21
|
+
".yss-harness-backend.json",
|
|
22
|
+
".yss-harness-frontend.json",
|
|
23
|
+
];
|
|
24
|
+
function validDescriptor(d, nullable = false) {
|
|
25
|
+
return (
|
|
26
|
+
(nullable && d === null) ||
|
|
27
|
+
Boolean(
|
|
28
|
+
d &&
|
|
29
|
+
d.type === "file" &&
|
|
30
|
+
/^[a-f0-9]{64}$/.test(d.digest) &&
|
|
31
|
+
Number.isInteger(d.mode) &&
|
|
32
|
+
d.mode >= 0 &&
|
|
33
|
+
d.mode <= 0o777,
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
export function identity(target, bundle, command, migrateLayout = false) {
|
|
38
|
+
const f = bundle.family,
|
|
39
|
+
present = METADATA.filter((ref) => stat(safe(target, ref)));
|
|
40
|
+
ensure(present.length <= 1, "检测到多个家族 metadata,拒绝混用", "IDENTITY");
|
|
41
|
+
ensure(
|
|
42
|
+
!present.length || present[0] === f.metadataFile,
|
|
43
|
+
"不同 Harness 家族不可接管或转换",
|
|
44
|
+
"IDENTITY",
|
|
45
|
+
);
|
|
46
|
+
let meta = null;
|
|
47
|
+
if (present.length) {
|
|
48
|
+
meta = readJson(target, f.metadataFile);
|
|
49
|
+
yaml(fs.readFileSync(safe(target, f.metadataFile)));
|
|
50
|
+
ensure(
|
|
51
|
+
meta && typeof meta === "object" && !Array.isArray(meta),
|
|
52
|
+
"metadata 必须是对象",
|
|
53
|
+
"IDENTITY",
|
|
54
|
+
);
|
|
55
|
+
ensure(
|
|
56
|
+
!("schema_version" in meta) &&
|
|
57
|
+
!("profile_id" in meta) &&
|
|
58
|
+
!("managed_files" in meta) &&
|
|
59
|
+
!("template_source" in meta),
|
|
60
|
+
"不支持旧实例;请在全新目录使用新 CLI init",
|
|
61
|
+
"LEGACY",
|
|
62
|
+
);
|
|
63
|
+
const legacy = f.side === "design" && meta.metadataSchemaVersion === 1;
|
|
64
|
+
if (legacy) meta = normalizeLegacy(meta, f);
|
|
65
|
+
if (!legacy) {
|
|
66
|
+
ensure(!meta.legacy, "非法 legacy 标记", "BASELINE");
|
|
67
|
+
ensure(
|
|
68
|
+
meta.metadataSchemaVersion === 2,
|
|
69
|
+
"不支持或损坏的 metadata schema",
|
|
70
|
+
"IDENTITY",
|
|
71
|
+
);
|
|
72
|
+
for (const key of ["profileId", "templateName", "templateSource"])
|
|
73
|
+
ensure(meta[key] === f[key], `metadata 身份矛盾: ${key}`, "IDENTITY");
|
|
74
|
+
ensure(
|
|
75
|
+
/^[a-f0-9]{40}$/.test(meta.templateCommit) &&
|
|
76
|
+
["committed", "working-tree"].includes(meta.templateSourceState || "committed") &&
|
|
77
|
+
["committed", "working-tree"].includes(meta.coreSourceState || "committed") &&
|
|
78
|
+
["snapshotHash", "coreDigest", "manifestHash", "baselineDigest"].every(
|
|
79
|
+
(k) => /^[a-f0-9]{64}$/.test(meta[k]),
|
|
80
|
+
),
|
|
81
|
+
"metadata 来源或摘要缺失",
|
|
82
|
+
"BASELINE",
|
|
83
|
+
);
|
|
84
|
+
ensure(
|
|
85
|
+
meta.managedFiles &&
|
|
86
|
+
typeof meta.managedFiles === "object" &&
|
|
87
|
+
!Array.isArray(meta.managedFiles) &&
|
|
88
|
+
hash(JSON.stringify(meta.managedFiles)) === meta.baselineDigest,
|
|
89
|
+
"基线缺失或摘要损坏;请使用保留的恢复清单",
|
|
90
|
+
"BASELINE",
|
|
91
|
+
);
|
|
92
|
+
for (const [ref, item] of Object.entries(meta.managedFiles)) {
|
|
93
|
+
governance(ref);
|
|
94
|
+
ensure(
|
|
95
|
+
validDescriptor(item.baseline) &&
|
|
96
|
+
validDescriptor(item.lastApplied, true),
|
|
97
|
+
`未知基线或文件类型: ${ref}`,
|
|
98
|
+
"BASELINE",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
for (const ref of ["yss-project.yaml", "AGENTS.md", "CONTEXT.md"])
|
|
102
|
+
ensure(meta.managedFiles[ref], `基线缺少必需合同: ${ref}`, "BASELINE");
|
|
103
|
+
ensure(meta.managedFiles[PROFILE] || (migrateLayout && meta.managedFiles["docs/process/harness-profile.yaml"]), `基线缺少必需合同: ${PROFILE}`, "BASELINE");
|
|
104
|
+
ensure(
|
|
105
|
+
meta.variables &&
|
|
106
|
+
typeof meta.variables.projectName === "string" &&
|
|
107
|
+
typeof meta.variables.includeExampleDocs === "boolean",
|
|
108
|
+
"项目变量缺失",
|
|
109
|
+
"BASELINE",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const profilePath = safe(target, stat(safe(target, PROFILE)) ? PROFILE : (migrateLayout ? "docs/process/harness-profile.yaml" : PROFILE));
|
|
114
|
+
let profile = null;
|
|
115
|
+
if (stat(profilePath)) {
|
|
116
|
+
profile = yaml(fs.readFileSync(profilePath));
|
|
117
|
+
ensure(
|
|
118
|
+
profile.instantiation?.cli_package !== "repository-local",
|
|
119
|
+
"不支持 repository-local 旧实例;仅允许全新目录 init",
|
|
120
|
+
"LEGACY",
|
|
121
|
+
);
|
|
122
|
+
ensure(
|
|
123
|
+
[1, 2].includes(profile.schema_version) &&
|
|
124
|
+
profile.profile_id === f.profileId,
|
|
125
|
+
"目标 profile 未知或属于不同家族",
|
|
126
|
+
"IDENTITY",
|
|
127
|
+
);
|
|
128
|
+
ensure(
|
|
129
|
+
(f.side === "design" && !profile.instantiation) || (profile.instantiation?.cli_package === f.packageName &&
|
|
130
|
+
profile.instantiation?.metadata_file === f.metadataFile &&
|
|
131
|
+
profile.instantiation?.template_source === f.templateSource),
|
|
132
|
+
"目标 profile 字段矛盾",
|
|
133
|
+
"IDENTITY",
|
|
134
|
+
);
|
|
135
|
+
ensure(
|
|
136
|
+
meta,
|
|
137
|
+
"profile 存在但新版 metadata 缺失,无法建立所有权",
|
|
138
|
+
"BASELINE",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const projectPath = safe(target, "yss-project.yaml");
|
|
142
|
+
let project = null;
|
|
143
|
+
if (stat(projectPath)) {
|
|
144
|
+
project = yaml(fs.readFileSync(projectPath));
|
|
145
|
+
ensure(
|
|
146
|
+
project.schema_version === 1 &&
|
|
147
|
+
project.repository_mode === "project-instance",
|
|
148
|
+
"仓库 schema 不支持或不是 project-instance",
|
|
149
|
+
"IDENTITY",
|
|
150
|
+
);
|
|
151
|
+
ensure(meta, "已有 Harness 身份但缺少新版 metadata", "BASELINE");
|
|
152
|
+
}
|
|
153
|
+
if (meta)
|
|
154
|
+
ensure(profile && project, "metadata 存在但身份合同缺失", "IDENTITY");
|
|
155
|
+
if (["sync", "diff", "doctor", "recover"].includes(command))
|
|
156
|
+
ensure(meta, "sync 仅支持新 CLI 创建或 attach 的实例", "BASELINE");
|
|
157
|
+
else ensure(!meta, "已接入该家族,请使用 sync", "IDENTITY");
|
|
158
|
+
return meta;
|
|
159
|
+
}
|
|
160
|
+
export function guardNestedRepository(target, ref) {
|
|
161
|
+
let current = path.dirname(ref);
|
|
162
|
+
while (current !== ".") {
|
|
163
|
+
ensure(
|
|
164
|
+
!stat(safe(target, `${current}/.git`)),
|
|
165
|
+
`受保护嵌套仓库或 gitlink: ${current}`,
|
|
166
|
+
"PROTECTED",
|
|
167
|
+
);
|
|
168
|
+
current = path.dirname(current);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function gitlinks(target) {
|
|
172
|
+
const paths = [];
|
|
173
|
+
const config = safe(target, ".gitmodules");
|
|
174
|
+
if (stat(config)) {
|
|
175
|
+
const r = spawnSync(
|
|
176
|
+
"git",
|
|
177
|
+
[
|
|
178
|
+
"config",
|
|
179
|
+
"--no-includes",
|
|
180
|
+
"--file",
|
|
181
|
+
config,
|
|
182
|
+
"--get-regexp",
|
|
183
|
+
"^submodule\\..*\\.path$",
|
|
184
|
+
],
|
|
185
|
+
{ encoding: "utf8" },
|
|
186
|
+
);
|
|
187
|
+
ensure(
|
|
188
|
+
[0, 1].includes(r.status),
|
|
189
|
+
`无法解析 .gitmodules: ${r.stderr}`,
|
|
190
|
+
"PROTECTED",
|
|
191
|
+
);
|
|
192
|
+
for (const line of r.stdout.trim().split("\n").filter(Boolean)) {
|
|
193
|
+
const value = line.replace(/^\S+\s+/, "");
|
|
194
|
+
relative(value);
|
|
195
|
+
paths.push(value);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (stat(path.join(target, ".git"))) {
|
|
199
|
+
const r = spawnSync(
|
|
200
|
+
"git",
|
|
201
|
+
[
|
|
202
|
+
"--no-optional-locks",
|
|
203
|
+
"-c",
|
|
204
|
+
"core.fsmonitor=false",
|
|
205
|
+
"-C",
|
|
206
|
+
target,
|
|
207
|
+
"ls-files",
|
|
208
|
+
"--stage",
|
|
209
|
+
"-z",
|
|
210
|
+
],
|
|
211
|
+
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
|
212
|
+
);
|
|
213
|
+
ensure(r.status === 0, `无法核验 Git index: ${r.stderr}`, "PROTECTED");
|
|
214
|
+
for (const record of r.stdout.split("\0"))
|
|
215
|
+
if (record.startsWith("160000 ")) {
|
|
216
|
+
const ref = record.slice(record.indexOf("\t") + 1);
|
|
217
|
+
relative(ref);
|
|
218
|
+
paths.push(ref);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [...new Set(paths)].sort();
|
|
222
|
+
}
|
|
223
|
+
// A partially applied init/attach may not have metadata yet. Every identity byte
|
|
224
|
+
// that does exist must still belong to this family and to the recorded transaction.
|
|
225
|
+
export function recoveryIdentity(target, bundle, state) {
|
|
226
|
+
const f = bundle.family;
|
|
227
|
+
for (const ref of METADATA)
|
|
228
|
+
if (ref !== f.metadataFile)
|
|
229
|
+
ensure(
|
|
230
|
+
!stat(safe(target, ref)),
|
|
231
|
+
"异族 metadata 禁止事务恢复",
|
|
232
|
+
"IDENTITY",
|
|
233
|
+
);
|
|
234
|
+
if (stat(safe(target, f.metadataFile))) identity(target, bundle, "sync");
|
|
235
|
+
else {
|
|
236
|
+
if (stat(safe(target, PROFILE))) {
|
|
237
|
+
const p = yaml(fs.readFileSync(safe(target, PROFILE)));
|
|
238
|
+
ensure(
|
|
239
|
+
p.instantiation?.cli_package !== "repository-local",
|
|
240
|
+
"旧实例不支持恢复或接管",
|
|
241
|
+
"LEGACY",
|
|
242
|
+
);
|
|
243
|
+
ensure(
|
|
244
|
+
[1, 2].includes(p.schema_version) &&
|
|
245
|
+
p.profile_id === f.profileId &&
|
|
246
|
+
((f.side === "design" && !p.instantiation) || (p.instantiation?.cli_package === f.packageName &&
|
|
247
|
+
p.instantiation?.metadata_file === f.metadataFile &&
|
|
248
|
+
p.instantiation?.template_source === f.templateSource)),
|
|
249
|
+
"恢复 profile 身份矛盾",
|
|
250
|
+
"IDENTITY",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (stat(safe(target, "yss-project.yaml"))) {
|
|
254
|
+
const p = yaml(fs.readFileSync(safe(target, "yss-project.yaml")));
|
|
255
|
+
ensure(
|
|
256
|
+
p.schema_version === 1 && p.repository_mode === "project-instance",
|
|
257
|
+
"恢复仓库身份非法",
|
|
258
|
+
"IDENTITY",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const ref of [f.metadataFile, PROFILE, "yss-project.yaml"]) {
|
|
263
|
+
const current = descriptor(target, ref),
|
|
264
|
+
ops = state.pending
|
|
265
|
+
.flatMap((x) => x.journal.operations)
|
|
266
|
+
.filter((x) => x.path === ref);
|
|
267
|
+
if (ops.length)
|
|
268
|
+
ensure(
|
|
269
|
+
ops.some((op) => same(current, op.before) || same(current, op.after)),
|
|
270
|
+
`恢复身份已发生后续修改: ${ref}`,
|
|
271
|
+
"IDENTITY",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|