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,8 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {assertUpgradeVersion} from '../migrate.mjs';
|
|
4
|
+
test('迁移版本顺序遵循预发布数字和正式版优先级',()=>{
|
|
5
|
+
for(const [from,to] of [['1.2.3-rc.2','1.2.3-rc.10'],['1.2.3-rc.10','1.2.3'],['1.2.3+old','1.2.3+new'],['1.2.3','1.3.0-beta.1']])assert.doesNotThrow(()=>assertUpgradeVersion(from,to));
|
|
6
|
+
for(const [from,to] of [['1.2.3','1.2.3-rc.1'],['1.2.3-rc.10','1.2.3-rc.2'],['2.0.0','1.9.9'],['1.2.3-beta','1.2.3-alpha'],['1.2.3-rc.1','1.2.3-rc']])assert.throws(()=>assertUpgradeVersion(from,to),e=>e.code==='VERSION');
|
|
7
|
+
for(const bad of ['1.2.3-','1.2.3-rc.01','01.2.3','latest'])assert.throws(()=>assertUpgradeVersion(bad,'2.0.0'),e=>e.code==='VERSION');
|
|
8
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {createHash} from 'node:crypto';
|
|
7
|
+
import {spawnSync} from 'node:child_process';
|
|
8
|
+
import {verifyCore} from '../build.mjs';
|
|
9
|
+
import {loadBundle} from '../bundle.mjs';
|
|
10
|
+
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
export function packageContract(root) {
|
|
12
|
+
const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
|
|
13
|
+
const family=JSON.parse(fs.readFileSync(path.join(root,'config/family.json')));
|
|
14
|
+
const entry=path.join(root,'bin',pkg.name+'.js');
|
|
15
|
+
test(`${family.side}: 真实包初始化、接入、诊断、只读、同步和家族边界`,t=>{
|
|
16
|
+
const scratch=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'harness-package-')));
|
|
17
|
+
t.after(()=>fs.rmSync(scratch,{recursive:true,force:true}));
|
|
18
|
+
const target=path.join(scratch,'project');
|
|
19
|
+
const run=(command,args=[],destination=target)=>{
|
|
20
|
+
const r=spawnSync(process.execPath,[entry,command,'--target-dir',destination,...args,'--json'],{encoding:'utf8',timeout:300000,maxBuffer:32*1024*1024});
|
|
21
|
+
let data;try{data=JSON.parse(r.stdout);}catch{}
|
|
22
|
+
return {...r,data};
|
|
23
|
+
};
|
|
24
|
+
const success=r=>{assert.equal(r.status,0,r.stderr || r.error?.message);assert.ok(r.data);return r.data;};
|
|
25
|
+
const init=['--project-name','验收项目','--business-domain','通用 CLI'];
|
|
26
|
+
const inputs=['template.snapshot.json','template.manifest.json','cli-core.lock.json'].map(p=>hash(fs.readFileSync(path.join(root,p))));
|
|
27
|
+
verifyCore(root);loadBundle(root);
|
|
28
|
+
success(run('init',[...init,'--dry-run']));assert.equal(fs.existsSync(target),false);
|
|
29
|
+
success(run('init',init));
|
|
30
|
+
const readme=path.join(target,'README.md'),context=path.join(target,'CONTEXT.md'),metadata=path.join(target,family.metadataFile);
|
|
31
|
+
fs.writeFileSync(readme,'# 用户文档\n');
|
|
32
|
+
const contextBefore=fs.readFileSync(context);
|
|
33
|
+
let before=fs.readFileSync(metadata);const journalCount=()=>fs.readdirSync(path.join(target,'.yss-harness-state',family.side,'transactions')).length;
|
|
34
|
+
const count=journalCount();
|
|
35
|
+
for(const command of ['doctor','diff','recover']) success(run(command));
|
|
36
|
+
success(run('sync',['--plan','--prune']));
|
|
37
|
+
assert.deepEqual(fs.readFileSync(metadata),before);assert.equal(journalCount(),count);
|
|
38
|
+
success(run('sync',['--apply','--prune']));
|
|
39
|
+
assert.equal(fs.readFileSync(readme,'utf8'),'# 用户文档\n');assert.deepEqual(fs.readFileSync(context),contextBefore);
|
|
40
|
+
before=fs.readFileSync(metadata);success(run('sync',['--apply']));assert.deepEqual(fs.readFileSync(metadata),before);
|
|
41
|
+
if(family.side==='design') {
|
|
42
|
+
const modern=JSON.parse(fs.readFileSync(metadata));
|
|
43
|
+
const legacy={metadataSchemaVersion:1,templateName:family.templateName,templateSource:family.templateSource,profileId:family.profileId,templateCommit:modern.templateCommit,templateSourceState:modern.templateSourceState,snapshotHash:modern.snapshotHash,managedFilesManifestVersion:modern.manifestHash,cliVersion:'0.5.2',variables:modern.variables,initializedAt:modern.initializedAt,managedFiles:Object.fromEntries(Object.entries(modern.managedFiles).map(([p,r])=>[p,{type:'copy',contentHash:r.baseline.digest}]))};
|
|
44
|
+
fs.writeFileSync(path.join(target,'scripts/instantiate-harness'),'historical creator');legacy.managedFiles['scripts/instantiate-harness']={type:'copy',contentHash:hash('historical creator')};
|
|
45
|
+
const legacyBytes=Buffer.from(JSON.stringify(legacy,null,2)+'\n');fs.writeFileSync(metadata,legacyBytes);
|
|
46
|
+
assert.equal(success(run('diff')).migrationRequired,true);success(run('doctor'));assert.deepEqual(fs.readFileSync(metadata),legacyBytes);
|
|
47
|
+
// Crash just after replacing metadata: recovery must still understand v1 before/v2 after.
|
|
48
|
+
const hook=path.join(scratch,'crash.mjs');fs.writeFileSync(hook,`import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module'; const rename=fs.renameSync;fs.renameSync=(a,b)=>{rename(a,b);if(String(b)===${JSON.stringify(metadata)})process.exit(73);};syncBuiltinESMExports();`);
|
|
49
|
+
const crashed=spawnSync(process.execPath,['--import',hook,entry,'sync','--target-dir',target,'--apply','--json'],{encoding:'utf8',timeout:300000});assert.equal(crashed.status,73,crashed.stderr);
|
|
50
|
+
success(run('recover'));success(run('recover',['--apply']));assert.deepEqual(fs.readFileSync(metadata),legacyBytes);
|
|
51
|
+
success(run('sync',['--apply']));assert.equal(JSON.parse(fs.readFileSync(metadata)).metadataSchemaVersion,2);assert.equal(fs.readFileSync(path.join(target,'scripts/instantiate-harness'),'utf8'),'historical creator');
|
|
52
|
+
assert.deepEqual(fs.readFileSync(context),contextBefore);
|
|
53
|
+
if(fs.existsSync(path.join(target,'scripts/verify-strategic-gate-migration'))) {
|
|
54
|
+
const active=path.join(target,'docs/.scratch/migration-fixture');fs.mkdirSync(active,{recursive:true});
|
|
55
|
+
fs.writeFileSync(path.join(active,'checkpoint.json'),JSON.stringify({repository_mode:'project-instance',gates:{'gate.domain-strategy-approved':{status:'approved'}}}));
|
|
56
|
+
const migrationRequired=run('doctor');assert.equal(migrationRequired.status,1);assert.equal(migrationRequired.data.status,'error');assert.equal(migrationRequired.data.checks.find(x=>x.name==='strategic-gate-migration')?.code,'STRATEGIC_GATE_MIGRATION_REQUIRED');
|
|
57
|
+
fs.rmSync(active,{recursive:true,force:true});success(run('doctor'));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const existing=path.join(scratch,'existing');fs.mkdirSync(path.join(existing,'src'),{recursive:true});
|
|
61
|
+
const userFiles={'README.md':'# existing\n','.gitignore':'private-local-rule\n','src/Main.java':'class Main {}\n','pom.xml':'<project/>\n','approved.yaml':'decision: approved\n'};
|
|
62
|
+
for(const [p,bytes]of Object.entries(userFiles))fs.writeFileSync(path.join(existing,p),bytes);
|
|
63
|
+
success(run('attach',[],existing));assert.equal(fs.existsSync(path.join(existing,'.yss-harness-state')),false);
|
|
64
|
+
success(run('attach',['--apply'],existing));success(run('doctor',[],existing));
|
|
65
|
+
for(const [p,bytes] of Object.entries(userFiles))assert.ok(p==='.gitignore'?fs.readFileSync(path.join(existing,p),'utf8').startsWith(bytes):fs.readFileSync(path.join(existing,p),'utf8')===bytes,p);
|
|
66
|
+
for(const foreign of ['.yss-template.json','.yss-harness-dev.json',...['design','backend','frontend'].filter(x=>x!==family.side).map(x=>`.yss-harness-${x}.json`)]) {
|
|
67
|
+
const dir=path.join(scratch,foreign.slice(1));fs.mkdirSync(dir);fs.writeFileSync(path.join(dir,foreign),'{}');
|
|
68
|
+
for(const args of [[],['--apply','--force']]) assert.equal(run('attach',args,dir).status,1);
|
|
69
|
+
assert.deepEqual(fs.readdirSync(dir),[foreign]);
|
|
70
|
+
}
|
|
71
|
+
assert.equal(run('init',[...init,'--force']).status,1);
|
|
72
|
+
for(const command of ['doctor','diff'])assert.equal(run(command,['--apply']).status,1);
|
|
73
|
+
assert.deepEqual(['template.snapshot.json','template.manifest.json','cli-core.lock.json'].map(p=>hash(fs.readFileSync(path.join(root,p)))),inputs);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
ensure,
|
|
8
|
+
safe,
|
|
9
|
+
stat,
|
|
10
|
+
descriptor,
|
|
11
|
+
same,
|
|
12
|
+
write,
|
|
13
|
+
readJson,
|
|
14
|
+
json,
|
|
15
|
+
hash,
|
|
16
|
+
targetPath,
|
|
17
|
+
governance,
|
|
18
|
+
} from "./io.mjs";
|
|
19
|
+
import { guardNestedRepository, gitlinks } from "./identity.mjs";
|
|
20
|
+
const STATE = ".yss-harness-state";
|
|
21
|
+
function syncDirectory(directory) {
|
|
22
|
+
const fd = fs.openSync(directory, "r");
|
|
23
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
24
|
+
}
|
|
25
|
+
function dirtyParents(root, ref, directories) {
|
|
26
|
+
let parent = path.dirname(ref);
|
|
27
|
+
while (parent !== ".") { directories.add(parent); parent = path.dirname(parent); }
|
|
28
|
+
directories.add(".");
|
|
29
|
+
}
|
|
30
|
+
function flushDirectories(root, directories) {
|
|
31
|
+
// Child directory entries reach disk before the parent which makes them reachable.
|
|
32
|
+
for (const ref of [...directories].sort((a, b) => (b === "." ? 0 : b.split("/").length) - (a === "." ? 0 : a.split("/").length))) {
|
|
33
|
+
const directory = ref === "." ? root : safe(root, ref);
|
|
34
|
+
if (stat(directory)?.isDirectory()) syncDirectory(directory);
|
|
35
|
+
}
|
|
36
|
+
directories.clear();
|
|
37
|
+
}
|
|
38
|
+
function operationTemporaryPath(ref, id, index) {
|
|
39
|
+
return `${ref}.${hash(`${id}:${index}`).slice(0, 36)}.tmp`;
|
|
40
|
+
}
|
|
41
|
+
function durable(root, ref, bytes, mode = 0o644, directories, temporaryPath, onCreated) {
|
|
42
|
+
const file = safe(root, ref);
|
|
43
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
44
|
+
const temp = temporaryPath ? safe(root, temporaryPath) : file + "." + randomUUID() + ".tmp";
|
|
45
|
+
let temporaryIdentity;
|
|
46
|
+
try {
|
|
47
|
+
const fd = fs.openSync(temp, "wx", mode);
|
|
48
|
+
try {
|
|
49
|
+
temporaryIdentity = fs.fstatSync(fd);
|
|
50
|
+
onCreated?.();
|
|
51
|
+
fs.writeFileSync(fd, bytes);
|
|
52
|
+
fs.fchmodSync(fd, mode);
|
|
53
|
+
fs.fsyncSync(fd);
|
|
54
|
+
} finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
fs.renameSync(temp, file);
|
|
58
|
+
if (directories) dirtyParents(root, ref, directories);
|
|
59
|
+
else syncDirectory(path.dirname(file));
|
|
60
|
+
} finally {
|
|
61
|
+
const remaining = temporaryIdentity && stat(temp);
|
|
62
|
+
if (remaining && remaining.dev === temporaryIdentity.dev && remaining.ino === temporaryIdentity.ino) fs.unlinkSync(temp);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function readWal(target, base, journal) {
|
|
66
|
+
const wal = safe(target, `${base}/intent.wal`);
|
|
67
|
+
if (!stat(wal)) { ensure(journal.phase === "backup", "事务 WAL 缺失", "STATE"); return; }
|
|
68
|
+
const bytes = fs.readFileSync(wal, "utf8");
|
|
69
|
+
// An incomplete final append precedes its fsync, therefore no target write was allowed.
|
|
70
|
+
const complete = bytes.slice(0, bytes.lastIndexOf("\n") + 1);
|
|
71
|
+
const created = new Set();
|
|
72
|
+
journal.operations.forEach(op => { op.attempted = false; });
|
|
73
|
+
let index = 0;
|
|
74
|
+
for (const line of complete.split("\n").filter(Boolean)) {
|
|
75
|
+
const record = JSON.parse(line);
|
|
76
|
+
ensure(record.schemaVersion === 1 && record.index === index && index < journal.operations.length && Array.isArray(record.createdDirectories), "事务 WAL 损坏", "STATE");
|
|
77
|
+
const op = journal.operations[index++];
|
|
78
|
+
for (const ref of record.createdDirectories) {
|
|
79
|
+
ensure(typeof ref === "string" && op.path.startsWith(ref + "/"), "WAL 目录不在操作范围", "STATE");
|
|
80
|
+
safe(target, ref); created.add(ref);
|
|
81
|
+
}
|
|
82
|
+
op.attempted = true;
|
|
83
|
+
}
|
|
84
|
+
if (journal.phase === "verify") ensure(index === journal.operations.length, "已验证事务的 WAL 不完整", "STATE");
|
|
85
|
+
journal.createdDirectories = [...created];
|
|
86
|
+
}
|
|
87
|
+
export function inspectState(target, family) {
|
|
88
|
+
const state = safe(target, STATE);
|
|
89
|
+
if (!stat(state)) return { pending: [] };
|
|
90
|
+
ensure(stat(state).isDirectory(), "状态路径不是目录", "STATE");
|
|
91
|
+
const owner = readJson(target, `${STATE}/owner.json`);
|
|
92
|
+
ensure(
|
|
93
|
+
owner.schemaVersion === 1 && owner.profileId === family.profileId,
|
|
94
|
+
"未知或异族状态目录,不覆盖",
|
|
95
|
+
"STATE",
|
|
96
|
+
);
|
|
97
|
+
const transactions = safe(target, `${STATE}/${family.side}/transactions`);
|
|
98
|
+
ensure(stat(transactions)?.isDirectory(), "状态目录不完整", "STATE");
|
|
99
|
+
const pending = [];
|
|
100
|
+
for (const name of fs.readdirSync(transactions)) {
|
|
101
|
+
ensure(/^[a-f0-9-]{36}$/.test(name), "未知事务目录", "STATE");
|
|
102
|
+
const base = `${STATE}/${family.side}/transactions/${name}`,
|
|
103
|
+
journal = readJson(target, `${base}/journal.json`);
|
|
104
|
+
ensure(
|
|
105
|
+
[1, 2].includes(journal.schemaVersion) &&
|
|
106
|
+
journal.id === name &&
|
|
107
|
+
journal.profileId === family.profileId &&
|
|
108
|
+
Array.isArray(journal.operations),
|
|
109
|
+
"事务日志损坏",
|
|
110
|
+
"STATE",
|
|
111
|
+
);
|
|
112
|
+
if (!["committed", "rolled-back"].includes(journal.phase)) {
|
|
113
|
+
for (const [index, op] of journal.operations.entries()) {
|
|
114
|
+
if (op.path !== family.metadataFile) governance(op.path);
|
|
115
|
+
safe(target, op.path);
|
|
116
|
+
if (op.temporaryPath != null) ensure(
|
|
117
|
+
journal.schemaVersion === 2 && op.after !== null &&
|
|
118
|
+
op.temporaryPath === operationTemporaryPath(op.path, journal.id, index),
|
|
119
|
+
"恢复临时文件不在事务操作范围", "STATE",
|
|
120
|
+
);
|
|
121
|
+
for (const d of [op.before, op.after])
|
|
122
|
+
ensure(
|
|
123
|
+
d === null ||
|
|
124
|
+
(d?.type === "file" &&
|
|
125
|
+
/^[a-f0-9]{64}$/.test(d.digest) &&
|
|
126
|
+
Number.isInteger(d.mode) &&
|
|
127
|
+
d.mode >= 0 &&
|
|
128
|
+
d.mode <= 0o777),
|
|
129
|
+
"恢复日志描述损坏",
|
|
130
|
+
"STATE",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (journal.schemaVersion === 2) readWal(target, base, journal);
|
|
134
|
+
else if (stat(safe(target, `${base}/progress.json`))) {
|
|
135
|
+
const progress = readJson(target, `${base}/progress.json`);
|
|
136
|
+
ensure(
|
|
137
|
+
Number.isInteger(progress.index) &&
|
|
138
|
+
progress.index >= 0 &&
|
|
139
|
+
progress.index < journal.operations.length,
|
|
140
|
+
"恢复进度损坏",
|
|
141
|
+
"STATE",
|
|
142
|
+
);
|
|
143
|
+
journal.operations.forEach(
|
|
144
|
+
(op, i) => (op.attempted = i <= progress.index),
|
|
145
|
+
);
|
|
146
|
+
journal.createdDirectories = progress.createdDirectories;
|
|
147
|
+
}
|
|
148
|
+
pending.push({ base, journal });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { pending };
|
|
152
|
+
}
|
|
153
|
+
export function applyTransaction(
|
|
154
|
+
target,
|
|
155
|
+
family,
|
|
156
|
+
operations,
|
|
157
|
+
validate,
|
|
158
|
+
id = randomUUID(),
|
|
159
|
+
gitInit = false,
|
|
160
|
+
verify = () => {},
|
|
161
|
+
) {
|
|
162
|
+
targetPath(target);
|
|
163
|
+
const initial = stat(target);
|
|
164
|
+
const newTargetParents = [];
|
|
165
|
+
for (let directory = target; !stat(directory); directory = path.dirname(directory)) {
|
|
166
|
+
newTargetParents.push(path.dirname(directory));
|
|
167
|
+
}
|
|
168
|
+
if (!initial) fs.mkdirSync(target, { recursive: true });
|
|
169
|
+
const prior = inspectState(target, family);
|
|
170
|
+
ensure(!prior.pending.length, "发现中断事务,请先恢复", "INTERRUPTED");
|
|
171
|
+
const createdState = !stat(safe(target, STATE));
|
|
172
|
+
if (createdState) {
|
|
173
|
+
fs.mkdirSync(safe(target, STATE));
|
|
174
|
+
durable(
|
|
175
|
+
target,
|
|
176
|
+
`${STATE}/owner.json`,
|
|
177
|
+
json({ schemaVersion: 1, profileId: family.profileId }),
|
|
178
|
+
);
|
|
179
|
+
fs.mkdirSync(safe(target, `${STATE}/${family.side}/transactions`), {
|
|
180
|
+
recursive: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
clearStaleLock(target);
|
|
184
|
+
const lockRef = `${STATE}/lock.json`,
|
|
185
|
+
lock = safe(target, lockRef);
|
|
186
|
+
let acquired = false,
|
|
187
|
+
base,
|
|
188
|
+
journal,
|
|
189
|
+
wal;
|
|
190
|
+
const directories = new Set();
|
|
191
|
+
try {
|
|
192
|
+
const fd = fs.openSync(lock, "wx");
|
|
193
|
+
acquired = true;
|
|
194
|
+
fs.writeFileSync(fd, json({ pid: process.pid, host: os.hostname() }));
|
|
195
|
+
fs.closeSync(fd);
|
|
196
|
+
validate();
|
|
197
|
+
for (const op of operations)
|
|
198
|
+
ensure(
|
|
199
|
+
same(descriptor(target, op.path), op.before),
|
|
200
|
+
`计划已过期: ${op.path}`,
|
|
201
|
+
"CONCURRENT",
|
|
202
|
+
);
|
|
203
|
+
base = `${STATE}/${family.side}/transactions/${id}`;
|
|
204
|
+
fs.mkdirSync(safe(target, base));
|
|
205
|
+
journal = {
|
|
206
|
+
schemaVersion: 2,
|
|
207
|
+
id,
|
|
208
|
+
profileId: family.profileId,
|
|
209
|
+
phase: "backup",
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
createdDirectories: [],
|
|
212
|
+
operations: operations.map(({ path, before, after }, index) => ({
|
|
213
|
+
path,
|
|
214
|
+
before,
|
|
215
|
+
after,
|
|
216
|
+
attempted: false,
|
|
217
|
+
temporaryPath: after ? operationTemporaryPath(path, id, index) : null,
|
|
218
|
+
})),
|
|
219
|
+
};
|
|
220
|
+
durable(target, `${base}/journal.json`, json(journal));
|
|
221
|
+
dirtyParents(target, `${base}/journal.json`, directories);
|
|
222
|
+
flushDirectories(target, directories);
|
|
223
|
+
// mkdir(recursive) can create ancestors outside the transaction root. Make
|
|
224
|
+
// those directory entries durable before any operation is allowed to start.
|
|
225
|
+
for (const parent of newTargetParents) syncDirectory(parent);
|
|
226
|
+
for (let i = 0; i < operations.length; i++) {
|
|
227
|
+
const op = operations[i];
|
|
228
|
+
if (op.before) {
|
|
229
|
+
const bytes = fs.readFileSync(safe(target, op.path));
|
|
230
|
+
ensure(
|
|
231
|
+
hash(bytes) === op.before.digest,
|
|
232
|
+
"备份期间发生并发变化",
|
|
233
|
+
"CONCURRENT",
|
|
234
|
+
);
|
|
235
|
+
durable(target, `${base}/backup/${i}`, bytes, op.before.mode);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
durable(target, `${base}/intent.wal`, Buffer.alloc(0));
|
|
239
|
+
wal = fs.openSync(safe(target, `${base}/intent.wal`), "a");
|
|
240
|
+
journal.phase = "apply";
|
|
241
|
+
durable(target, `${base}/journal.json`, json(journal));
|
|
242
|
+
const createdDirectories = new Set();
|
|
243
|
+
for (let i = 0; i < operations.length; i++) {
|
|
244
|
+
const op = operations[i];
|
|
245
|
+
if (op.path === family.metadataFile) flushDirectories(target, directories);
|
|
246
|
+
targetPath(target);
|
|
247
|
+
validate(op);
|
|
248
|
+
ensure(
|
|
249
|
+
same(descriptor(target, op.path), op.before),
|
|
250
|
+
`写入前发生并发变化: ${op.path}`,
|
|
251
|
+
"CONCURRENT",
|
|
252
|
+
);
|
|
253
|
+
const missing = [];
|
|
254
|
+
let parent = path.dirname(op.path);
|
|
255
|
+
while (parent !== "." && !stat(safe(target, parent))) {
|
|
256
|
+
missing.unshift(parent);
|
|
257
|
+
parent = path.dirname(parent);
|
|
258
|
+
}
|
|
259
|
+
const added = missing.filter(dir => !createdDirectories.has(dir));
|
|
260
|
+
const state = journal.operations[i];
|
|
261
|
+
if (state.temporaryPath) ensure(!stat(safe(target, state.temporaryPath)), "事务临时路径已有文件", "CONCURRENT");
|
|
262
|
+
fs.writeFileSync(wal, JSON.stringify({ schemaVersion: 1, index: i, createdDirectories: added }) + "\n");
|
|
263
|
+
fs.fsyncSync(wal);
|
|
264
|
+
for (const dir of added) { createdDirectories.add(dir); journal.createdDirectories.push(dir); }
|
|
265
|
+
journal.operations[i].attempted = true;
|
|
266
|
+
if (op.after) {
|
|
267
|
+
state.temporaryCreated = false;
|
|
268
|
+
durable(target, op.path, op.bytes, op.after.mode, directories, state.temporaryPath, () => { state.temporaryCreated = true; });
|
|
269
|
+
}
|
|
270
|
+
else { fs.unlinkSync(safe(target, op.path)); dirtyParents(target, op.path, directories); }
|
|
271
|
+
}
|
|
272
|
+
// Retired managed files must not leave discoverable empty skill roots.
|
|
273
|
+
// Only walk ancestors of deletions; never remove nonempty user directories.
|
|
274
|
+
for (const op of operations.filter((item) => !item.after)) {
|
|
275
|
+
let parent = path.dirname(op.path);
|
|
276
|
+
while (parent !== ".") {
|
|
277
|
+
guardRecoveryPath(target, parent + "/__retirement_check__");
|
|
278
|
+
const directory = safe(target, parent);
|
|
279
|
+
if (!stat(directory)?.isDirectory() || fs.readdirSync(directory).length) break;
|
|
280
|
+
fs.rmdirSync(directory);
|
|
281
|
+
parent = path.dirname(parent);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
flushDirectories(target, directories);
|
|
285
|
+
validate();
|
|
286
|
+
journal.phase = "verify";
|
|
287
|
+
durable(target, `${base}/journal.json`, json(journal));
|
|
288
|
+
for (const op of operations)
|
|
289
|
+
ensure(
|
|
290
|
+
same(descriptor(target, op.path), op.after),
|
|
291
|
+
`应用后校验失败: ${op.path}`,
|
|
292
|
+
"VERIFY",
|
|
293
|
+
);
|
|
294
|
+
verify();
|
|
295
|
+
if (gitInit) {
|
|
296
|
+
const work = safe(target, `${base}/git-staging`);
|
|
297
|
+
fs.mkdirSync(work);
|
|
298
|
+
const run = spawnSync(
|
|
299
|
+
"git",
|
|
300
|
+
["-c", "init.templateDir=", "init", "--initial-branch=main", work],
|
|
301
|
+
{ encoding: "utf8" },
|
|
302
|
+
);
|
|
303
|
+
ensure(run.status === 0, `git init 失败: ${run.stderr}`, "GIT");
|
|
304
|
+
journal.gitConfigHash = hash(
|
|
305
|
+
fs.readFileSync(path.join(work, ".git/config")),
|
|
306
|
+
);
|
|
307
|
+
durable(target, `${base}/journal.json`, json(journal));
|
|
308
|
+
ensure(!stat(safe(target, ".git")), "Git 路径并发冲突", "CONCURRENT");
|
|
309
|
+
fs.renameSync(path.join(work, ".git"), safe(target, ".git"));
|
|
310
|
+
syncDirectory(target);
|
|
311
|
+
}
|
|
312
|
+
journal.phase = "committed";
|
|
313
|
+
durable(target, `${base}/journal.json`, json(journal));
|
|
314
|
+
return { transactionId: id, backupPath: path.join(target, base) };
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (wal !== undefined) { fs.closeSync(wal); wal = undefined; }
|
|
317
|
+
if (journal) {
|
|
318
|
+
const unrestored = restore(target, base, journal, validate);
|
|
319
|
+
if (unrestored.length)
|
|
320
|
+
throw Object.assign(
|
|
321
|
+
new Error(
|
|
322
|
+
`${error.message};自动恢复未完成: ${unrestored.join(", ")};恢复清单: ${path.join(target, base)}`,
|
|
323
|
+
),
|
|
324
|
+
{ code: "RECOVERY_FAILED" },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
throw error;
|
|
328
|
+
} finally {
|
|
329
|
+
if (wal !== undefined) fs.closeSync(wal);
|
|
330
|
+
if (acquired && stat(lock)) fs.unlinkSync(lock);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function inspectTemporary(target, op) {
|
|
334
|
+
if (!op.temporaryPath) return null;
|
|
335
|
+
guardRecoveryPath(target, op.temporaryPath);
|
|
336
|
+
const current = descriptor(target, op.temporaryPath);
|
|
337
|
+
ensure(!current || (op.temporaryCreated !== false && same(current, op.after)),
|
|
338
|
+
`恢复临时文件无法证明归属或已有后续修改: ${op.temporaryPath}`, "RECOVERY_FAILED");
|
|
339
|
+
return current;
|
|
340
|
+
}
|
|
341
|
+
function restore(target, base, journal, validate = () => {}) {
|
|
342
|
+
const failed = [];
|
|
343
|
+
const directories = new Set();
|
|
344
|
+
if (journal.gitConfigHash && stat(safe(target, ".git"))) {
|
|
345
|
+
try {
|
|
346
|
+
ensure(
|
|
347
|
+
hash(fs.readFileSync(safe(target, ".git/config"))) ===
|
|
348
|
+
journal.gitConfigHash && !stat(safe(target, ".git/index")),
|
|
349
|
+
"Git 已有后续修改",
|
|
350
|
+
);
|
|
351
|
+
fs.rmSync(safe(target, ".git"), { recursive: true });
|
|
352
|
+
syncDirectory(target);
|
|
353
|
+
} catch {
|
|
354
|
+
failed.push(".git");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (let i = journal.operations.length - 1; i >= 0; i--) {
|
|
358
|
+
const op = journal.operations[i];
|
|
359
|
+
if (!op.attempted) continue;
|
|
360
|
+
let failedPath = op.path;
|
|
361
|
+
try {
|
|
362
|
+
validate(op);
|
|
363
|
+
guardRecoveryPath(target, op.path);
|
|
364
|
+
dirtyParents(target, op.path, directories);
|
|
365
|
+
if (op.temporaryPath) {
|
|
366
|
+
failedPath = op.temporaryPath;
|
|
367
|
+
if (inspectTemporary(target, op)) fs.unlinkSync(safe(target, op.temporaryPath));
|
|
368
|
+
failedPath = op.path;
|
|
369
|
+
}
|
|
370
|
+
const current = descriptor(target, op.path);
|
|
371
|
+
if (same(current, op.before)) continue;
|
|
372
|
+
ensure(
|
|
373
|
+
same(current, op.after),
|
|
374
|
+
`检测到后续修改: ${op.path}`,
|
|
375
|
+
"CONCURRENT",
|
|
376
|
+
);
|
|
377
|
+
if (op.before) {
|
|
378
|
+
const bytes = fs.readFileSync(safe(target, `${base}/backup/${i}`));
|
|
379
|
+
ensure(hash(bytes) === op.before.digest, "备份摘要损坏");
|
|
380
|
+
durable(target, op.path, bytes, op.before.mode);
|
|
381
|
+
} else if (current) {
|
|
382
|
+
fs.unlinkSync(safe(target, op.path));
|
|
383
|
+
syncDirectory(path.dirname(safe(target, op.path)));
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
failed.push(failedPath);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (const dir of [...(journal.createdDirectories || [])].reverse()) {
|
|
390
|
+
try {
|
|
391
|
+
ensure(
|
|
392
|
+
journal.operations.some((op) => op.path.startsWith(dir + "/")),
|
|
393
|
+
"恢复目录不在事务范围",
|
|
394
|
+
);
|
|
395
|
+
guardRecoveryPath(target, dir + "/__recovery_check__");
|
|
396
|
+
const p = safe(target, dir);
|
|
397
|
+
if (stat(p)?.isDirectory() && !fs.readdirSync(p).length) { fs.rmdirSync(p); syncDirectory(path.dirname(p)); }
|
|
398
|
+
} catch {
|
|
399
|
+
failed.push(dir);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
try { flushDirectories(target, directories); } catch { failed.push("directory-persistence"); }
|
|
403
|
+
journal.phase = failed.length ? "recovery-failed" : "rolled-back";
|
|
404
|
+
journal.unrestored = failed;
|
|
405
|
+
durable(target, `${base}/journal.json`, json(journal));
|
|
406
|
+
return failed;
|
|
407
|
+
}
|
|
408
|
+
function checkedRecoveryLock(target) {
|
|
409
|
+
const lock = safe(target, `${STATE}/lock.json`);
|
|
410
|
+
if (stat(lock)) {
|
|
411
|
+
const holder = readJson(target, `${STATE}/lock.json`);
|
|
412
|
+
ensure(
|
|
413
|
+
holder.host === os.hostname() &&
|
|
414
|
+
Number.isInteger(holder.pid) &&
|
|
415
|
+
holder.pid > 0,
|
|
416
|
+
"无法判断事务持有者,保留恢复清单",
|
|
417
|
+
"LOCKED",
|
|
418
|
+
);
|
|
419
|
+
let alive = true;
|
|
420
|
+
try {
|
|
421
|
+
process.kill(holder.pid, 0);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
if (e.code === "ESRCH") alive = false;
|
|
424
|
+
else throw e;
|
|
425
|
+
}
|
|
426
|
+
ensure(!alive, "另一个 CLI 正在运行", "LOCKED");
|
|
427
|
+
|
|
428
|
+
}
|
|
429
|
+
return lock;
|
|
430
|
+
}
|
|
431
|
+
export function recoveryPreview(target, family, state, validateIdentity = () => {}) {
|
|
432
|
+
// All recovery inputs are checked before touching even the stale lock.
|
|
433
|
+
validateIdentity();
|
|
434
|
+
for (const { base, journal } of state.pending) {
|
|
435
|
+
for (let i = 0; i < journal.operations.length; i++) {
|
|
436
|
+
const op = journal.operations[i];
|
|
437
|
+
if (!op.attempted) continue;
|
|
438
|
+
guardRecoveryPath(target, op.path);
|
|
439
|
+
inspectTemporary(target, op);
|
|
440
|
+
const current = descriptor(target, op.path);
|
|
441
|
+
ensure(
|
|
442
|
+
same(current, op.before) || same(current, op.after),
|
|
443
|
+
`恢复目标已有后续修改: ${op.path}`,
|
|
444
|
+
"RECOVERY_FAILED",
|
|
445
|
+
);
|
|
446
|
+
if (op.before && !same(current, op.before)) {
|
|
447
|
+
const bytes = fs.readFileSync(safe(target, `${base}/backup/${i}`));
|
|
448
|
+
ensure(
|
|
449
|
+
hash(bytes) === op.before.digest,
|
|
450
|
+
`恢复备份摘要损坏: ${op.path}`,
|
|
451
|
+
"RECOVERY_FAILED",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const dir of journal.createdDirectories || []) {
|
|
456
|
+
ensure(
|
|
457
|
+
journal.operations.some((op) => op.path.startsWith(dir + "/")),
|
|
458
|
+
"恢复目录不在事务范围",
|
|
459
|
+
"STATE",
|
|
460
|
+
);
|
|
461
|
+
guardRecoveryPath(target, dir + "/__recovery_check__");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (state.pending.length) checkedRecoveryLock(target);
|
|
465
|
+
return {schemaVersion:1,command:'recover',status:'preview',target,transactions:state.pending.map(({journal,base})=>({id:journal.id,phase:journal.phase,backupPath:path.join(target,base),paths:journal.operations.filter(op=>op.attempted).map(op=>op.path)}))};
|
|
466
|
+
}
|
|
467
|
+
export function recover(target, family, state, validateIdentity) {
|
|
468
|
+
recoveryPreview(target, family, state, validateIdentity);
|
|
469
|
+
const lock = checkedRecoveryLock(target);
|
|
470
|
+
if (stat(lock)) fs.unlinkSync(lock);
|
|
471
|
+
const fd = fs.openSync(lock, "wx");
|
|
472
|
+
fs.writeFileSync(fd, json({ pid: process.pid, host: os.hostname() }));
|
|
473
|
+
fs.closeSync(fd);
|
|
474
|
+
try {
|
|
475
|
+
const recovered = [];
|
|
476
|
+
for (const { base, journal } of state.pending) {
|
|
477
|
+
const failed = restore(target, base, journal, validateIdentity);
|
|
478
|
+
ensure(
|
|
479
|
+
!failed.length,
|
|
480
|
+
`自动恢复未完成: ${failed.join(", ")};保留 ${path.join(target, base)}`,
|
|
481
|
+
"RECOVERY_FAILED",
|
|
482
|
+
);
|
|
483
|
+
recovered.push(journal.id);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
schemaVersion: 1,
|
|
487
|
+
status: "recovered",
|
|
488
|
+
target,
|
|
489
|
+
transactions: recovered,
|
|
490
|
+
message: "中断事务已恢复,请重新执行预览或 apply",
|
|
491
|
+
};
|
|
492
|
+
} finally {
|
|
493
|
+
fs.unlinkSync(lock);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function clearStaleLock(target) {
|
|
498
|
+
const ref = `${STATE}/lock.json`,
|
|
499
|
+
file = safe(target, ref);
|
|
500
|
+
if (!stat(file)) return;
|
|
501
|
+
const holder = readJson(target, ref);
|
|
502
|
+
ensure(
|
|
503
|
+
holder.host === os.hostname() &&
|
|
504
|
+
Number.isInteger(holder.pid) &&
|
|
505
|
+
holder.pid > 0,
|
|
506
|
+
"无法判断锁持有者",
|
|
507
|
+
"LOCKED",
|
|
508
|
+
);
|
|
509
|
+
let alive = true;
|
|
510
|
+
try {
|
|
511
|
+
process.kill(holder.pid, 0);
|
|
512
|
+
} catch (e) {
|
|
513
|
+
if (e.code === "ESRCH") alive = false;
|
|
514
|
+
else throw e;
|
|
515
|
+
}
|
|
516
|
+
ensure(!alive, "另一个 CLI 正在运行", "LOCKED");
|
|
517
|
+
fs.unlinkSync(file);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function guardRecoveryPath(target, ref) {
|
|
521
|
+
targetPath(target);
|
|
522
|
+
safe(target, ref);
|
|
523
|
+
guardNestedRepository(target, ref);
|
|
524
|
+
ensure(
|
|
525
|
+
!gitlinks(target).some((x) => ref === x || ref.startsWith(x + "/")),
|
|
526
|
+
`受保护恢复 gitlink: ${ref}`,
|
|
527
|
+
"PROTECTED",
|
|
528
|
+
);
|
|
529
|
+
}
|