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.
Files changed (104) hide show
  1. package/README.md +8 -0
  2. package/cli-core.lock.json +130 -0
  3. package/package.json +7 -3
  4. package/resources/upgrade-skill/SKILL.md +26 -0
  5. package/resources/upgrade-skill/protocol.md +40 -0
  6. package/resources/upgrade-skill/references/cli-families.md +14 -0
  7. package/src/cli/help.js +1 -0
  8. package/src/cli/index.js +12 -0
  9. package/src/filesystem/transaction-runner.js +27 -0
  10. package/src/template/asset-runtime.js +6 -0
  11. package/src/template/verification-runtime.js +9 -1
  12. package/template/.agents/skills/.strategic-design-skills-manifest.json +2 -2
  13. package/template/.agents/skills/yss-harness-upgrade/SKILL.md +26 -0
  14. package/template/.agents/skills/yss-harness-upgrade/references/cli-families.md +14 -0
  15. package/template/.agents/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
  16. package/template/.agents/skills/yss-product-lifecycle/SKILL.md +15 -19
  17. package/template/.agents/skills/yss-stage-decision/SKILL.md +3 -0
  18. package/template/.agents/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
  19. package/template/.agents/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
  20. package/template/.agents/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
  21. package/template/.codex/skills/yss-harness-upgrade/SKILL.md +26 -0
  22. package/template/.codex/skills/yss-harness-upgrade/references/cli-families.md +14 -0
  23. package/template/.codex/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
  24. package/template/.codex/skills/yss-product-lifecycle/SKILL.md +15 -19
  25. package/template/.codex/skills/yss-stage-decision/SKILL.md +3 -0
  26. package/template/.codex/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
  27. package/template/.codex/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
  28. package/template/.codex/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
  29. package/template/.cursor/skills/yss-harness-upgrade/SKILL.md +26 -0
  30. package/template/.cursor/skills/yss-harness-upgrade/references/cli-families.md +14 -0
  31. package/template/.cursor/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
  32. package/template/.cursor/skills/yss-product-lifecycle/SKILL.md +15 -19
  33. package/template/.cursor/skills/yss-stage-decision/SKILL.md +3 -0
  34. package/template/.cursor/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
  35. package/template/.cursor/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
  36. package/template/.cursor/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
  37. package/template/.pi/skills/yss-harness-upgrade/SKILL.md +26 -0
  38. package/template/.pi/skills/yss-harness-upgrade/references/cli-families.md +14 -0
  39. package/template/.pi/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
  40. package/template/.pi/skills/yss-product-lifecycle/SKILL.md +15 -19
  41. package/template/.pi/skills/yss-stage-decision/SKILL.md +3 -0
  42. package/template/.pi/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
  43. package/template/.pi/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
  44. package/template/.pi/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
  45. package/template/.template-spec/agents/yss-skill-registry.yaml +9 -1
  46. package/template/.template-spec/process/contract-reading.md +1 -1
  47. package/template/.template-spec/process/document-writing.md +12 -0
  48. package/template/.template-spec/process/harness-upgrade.md +40 -0
  49. package/template/.template-spec/process/lifecycle-artifact-map.md +2 -2
  50. package/template/.template-spec/process/lifecycle-registry.yaml +3 -1
  51. package/template/.template-spec/process/schemas/approval-record.schema.json +23 -0
  52. package/template/.template-spec/process/stage-tracking.md +2 -2
  53. package/template/.template-spec/process/structured-assets.md +35 -0
  54. package/template/.template-spec/process/templates/lifecycle-checkpoint-template.json +48 -0
  55. package/template/.template-spec/templates/approval-record-template.json +23 -0
  56. package/template/scripts/check-plan-stage-coverage +12 -0
  57. package/template/scripts/contract +6 -0
  58. package/template/scripts/lib/approval-record.mjs +4 -3
  59. package/template/scripts/lib/asset-command.mjs +35 -0
  60. package/template/scripts/lib/asset-migration.mjs +101 -0
  61. package/template/scripts/lib/asset-transactions.mjs +235 -0
  62. package/template/scripts/lib/checkpoint-boundary.mjs +3 -0
  63. package/template/scripts/lib/contract-views.mjs +7 -1
  64. package/template/scripts/lib/json-schema.mjs +43 -4
  65. package/template/scripts/lib/lifecycle-context-query.mjs +7 -0
  66. package/template/scripts/lib/lifecycle-presentation.mjs +77 -0
  67. package/template/scripts/lib/lifecycle-status.mjs +16 -1
  68. package/template/scripts/lib/plan-stage-coverage.mjs +40 -0
  69. package/template/scripts/lib/reading-view-bundle.mjs +1 -0
  70. package/template/scripts/lib/reading-view-markdown.mjs +8 -6
  71. package/template/scripts/lib/stage-tracking-migration.mjs +11 -3
  72. package/template/scripts/lib/structured-assets.mjs +81 -0
  73. package/template/scripts/lib/task-package.mjs +4 -1
  74. package/template/scripts/lifecycle-status +6 -3
  75. package/template/scripts/verify-lifecycle-checkpoint +8 -5
  76. package/template/skills-lock.json +21 -9
  77. package/template.manifest.json +2 -1
  78. package/template.snapshot.json +5 -5
  79. package/vendor/cli-core/build.mjs +302 -0
  80. package/vendor/cli-core/bundle.mjs +198 -0
  81. package/vendor/cli-core/cli.mjs +182 -0
  82. package/vendor/cli-core/command-runner.mjs +107 -0
  83. package/vendor/cli-core/engine.mjs +239 -0
  84. package/vendor/cli-core/family.mjs +28 -0
  85. package/vendor/cli-core/gitignore.mjs +15 -0
  86. package/vendor/cli-core/identity.mjs +274 -0
  87. package/vendor/cli-core/io.mjs +153 -0
  88. package/vendor/cli-core/layout-migration.mjs +64 -0
  89. package/vendor/cli-core/legacy.mjs +19 -0
  90. package/vendor/cli-core/migrate.mjs +237 -0
  91. package/vendor/cli-core/migration-adapter.mjs +19 -0
  92. package/vendor/cli-core/package.json +13 -0
  93. package/vendor/cli-core/scaffold.mjs +117 -0
  94. package/vendor/cli-core/spec-migration-adapter.mjs +83 -0
  95. package/vendor/cli-core/tests/build.test.mjs +40 -0
  96. package/vendor/cli-core/tests/cli.test.mjs +1065 -0
  97. package/vendor/cli-core/tests/migration-version.test.mjs +8 -0
  98. package/vendor/cli-core/tests/package-contract.mjs +75 -0
  99. package/vendor/cli-core/transaction.mjs +529 -0
  100. package/vendor/cli-core/update.mjs +79 -0
  101. package/vendor/cli-core/vendor/NOTICES.md +6 -0
  102. package/vendor/cli-core/vendor/yaml.LICENSE +13 -0
  103. package/vendor/cli-core/vendor/yaml.mjs +6311 -0
  104. 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
+ }