@geoly-ai/skills-hub 0.1.0
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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/skills-hub.mjs +26 -0
- package/package.json +44 -0
- package/src/adapters/index.mjs +832 -0
- package/src/artifact.mjs +376 -0
- package/src/atomic-fs.mjs +166 -0
- package/src/attestation.mjs +136 -0
- package/src/canonical-json.mjs +147 -0
- package/src/cli.mjs +208 -0
- package/src/commands/check.mjs +295 -0
- package/src/commands/context.mjs +235 -0
- package/src/commands/install.mjs +430 -0
- package/src/commands/locks.mjs +197 -0
- package/src/commands/output.mjs +127 -0
- package/src/commands/query.mjs +266 -0
- package/src/commands/recover.mjs +438 -0
- package/src/commands/registry.mjs +123 -0
- package/src/commands/resolve.mjs +171 -0
- package/src/commands/snapshot-access.mjs +91 -0
- package/src/commands/sync-lock.mjs +189 -0
- package/src/crc32c.mjs +27 -0
- package/src/exit-codes.mjs +265 -0
- package/src/fault-inject.mjs +379 -0
- package/src/install.mjs +732 -0
- package/src/journal.mjs +435 -0
- package/src/ledger.mjs +671 -0
- package/src/lock.mjs +98 -0
- package/src/lockfile.mjs +0 -0
- package/src/pack.mjs +792 -0
- package/src/packer.mjs +351 -0
- package/src/plan.mjs +519 -0
- package/src/recover.mjs +1345 -0
- package/src/safe-fs.mjs +252 -0
- package/src/sigstore.mjs +480 -0
- package/src/snapshot.mjs +528 -0
- package/src/stats.mjs +59 -0
- package/src/target.mjs +738 -0
- package/src/telemetry.mjs +393 -0
- package/src/tree-digest.mjs +103 -0
- package/src/trust-roots/README.md +31 -0
- package/src/trust-roots/sigstore-public-good.json +126 -0
- package/src/trust.mjs +563 -0
- package/src/untar.mjs +570 -0
- package/src/upload.mjs +268 -0
- package/src/vendor.mjs +465 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// `recover [--continue | --rollback | --reinstall] …` —— 09-cli.md §1.1 的分流矩阵。
|
|
2
|
+
//
|
|
3
|
+
// ── 分流矩阵(09-cli.md §1.1 逐格)─────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// | 现场 | --continue | --rollback | --reinstall |
|
|
6
|
+
// |----------------------------------------|-----------|-----------|-------------|
|
|
7
|
+
// | 物理 `items[*].state = corrupt` | 拒绝 | 拒绝 | **唯一出路** |
|
|
8
|
+
// | `adopt` 的 assertion-corrupt | 拒绝 | **允许** | 不自动执行 |
|
|
9
|
+
// | `unadopt` 的 assertion-corrupt | **唯一自动出路** | 拒绝 | 不自动执行 |
|
|
10
|
+
// | 无异常,`phase = prepared` | 续做 | 反向段回滚 | 🔴 拒绝(无物理 corrupt) |
|
|
11
|
+
// | `phase = cleanup_pending` | 续做清理 | 反向段回滚 | 🔴 拒绝(无物理 corrupt) |
|
|
12
|
+
// | 已持久化 `direction = rollback` | 🔴 只能续回滚(三个 flag 都是) |||
|
|
13
|
+
// | 存在 `repair-intent.json` | 停机提示 --reinstall | 同左 | 续做状态机 |
|
|
14
|
+
// | 有 tx 无 journal(pre-commit) | 直接删(三个 flag 都是,也含无 flag) |||
|
|
15
|
+
// | `phase = completed` 的 journal 残留 | 清残留(同上) |||
|
|
16
|
+
//
|
|
17
|
+
// 🔴 **内核在 `cleanup_pending` 这一列与规格不符,本文件把它挡在前面。**
|
|
18
|
+
//
|
|
19
|
+
// `src/recover.mjs` 的 `driveLive()` 里那句
|
|
20
|
+
// `if (J.phase === 'cleanup_pending' && mode !== 'rollback') { runCleanup(...); return; }`
|
|
21
|
+
// 排在**读 `items[*].state === 'corrupt'` 之前**,于是产生两个与 §1.1 不符的格子:
|
|
22
|
+
//
|
|
23
|
+
// ① `cleanup_pending` + **无**物理 corrupt + `--reinstall`
|
|
24
|
+
// → 规格:拒绝(「仅物理 corrupt 可用」)。内核:静默做了一次清理续做。
|
|
25
|
+
// ② `cleanup_pending` + **有**物理 corrupt + 任意 flag
|
|
26
|
+
// → 规格:拒绝 / 拒绝 / 唯一出路。内核:三条都变成清理续做,
|
|
27
|
+
// 连「--continue 遇物理 corrupt 要拒绝」都被绕过去了。
|
|
28
|
+
//
|
|
29
|
+
// 修内核不在本块的文件边界内,所以 `assertModeAllowed()` 在**调用之前**用
|
|
30
|
+
// `inspect()` 判并拒绝这两格:① 报「仅物理 corrupt 可用」,② 整格转人工。
|
|
31
|
+
// 两条都写进了交付汇报(内核缺口:那个条件要把 `mode` 与 `items[*].state` 一起纳入)。
|
|
32
|
+
|
|
33
|
+
import { existsSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { layout } from '../ledger.mjs';
|
|
36
|
+
import { readJournal, listJournalGenerations } from '../journal.mjs';
|
|
37
|
+
import { readHistoricalSnapshot } from '../snapshot.mjs';
|
|
38
|
+
import { withVerifiedArtifact } from '../artifact.mjs';
|
|
39
|
+
import { STATE_DIR } from '../adapters/index.mjs';
|
|
40
|
+
import { recover as kernelRecover, inspect, planFromGeneration } from '../recover.mjs';
|
|
41
|
+
import { resetGeneration } from '../ledger.mjs';
|
|
42
|
+
import { planTargets, assertPlanOk } from '../adapters/index.mjs';
|
|
43
|
+
import { withOrderedLocks } from './locks.mjs';
|
|
44
|
+
import { makeLockfileHook } from './sync-lock.mjs';
|
|
45
|
+
import { UsageError, EXIT, classify } from '../exit-codes.mjs';
|
|
46
|
+
import { getVerifier } from './snapshot-access.mjs';
|
|
47
|
+
import { uintArg } from './context.mjs';
|
|
48
|
+
import { annotations } from './output.mjs';
|
|
49
|
+
|
|
50
|
+
const MODE_FLAGS = ['--continue', '--rollback', '--reinstall'];
|
|
51
|
+
|
|
52
|
+
export function parseRecoverArgs(argv) {
|
|
53
|
+
const o = {
|
|
54
|
+
mode: null, fromGeneration: null, only: [], resetGeneration: null,
|
|
55
|
+
releaseFrozen: null, resumeCleanup: false,
|
|
56
|
+
};
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
const eq = a.indexOf('=');
|
|
60
|
+
const name = eq > 2 ? a.slice(0, eq) : a;
|
|
61
|
+
let val = eq > 2 ? a.slice(eq + 1) : undefined;
|
|
62
|
+
const need = () => {
|
|
63
|
+
if (val === undefined) val = argv[++i];
|
|
64
|
+
if (val === undefined || val.startsWith('-')) throw new UsageError(`${name} 需要一个值`);
|
|
65
|
+
return val;
|
|
66
|
+
};
|
|
67
|
+
switch (name) {
|
|
68
|
+
case '--continue': case '--rollback': case '--reinstall':
|
|
69
|
+
if (o.mode !== null) {
|
|
70
|
+
throw new UsageError(
|
|
71
|
+
`${MODE_FLAGS.join(' / ')} 三者**互斥**,只能给一个(得到 ${o.mode} 与 ${name})。`
|
|
72
|
+
+ '它们是三条不同的出路,不是可以叠加的选项。',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
o.mode = name;
|
|
76
|
+
break;
|
|
77
|
+
case '--from-generation': o.fromGeneration = uintArg(name, need()); break;
|
|
78
|
+
// 🔴 `--only` **可重复**;重复给同一个 name 视为一次(去重)
|
|
79
|
+
case '--only': o.only.push(need()); break;
|
|
80
|
+
case '--reset-generation': o.resetGeneration = uintArg(name, need()); break;
|
|
81
|
+
case '--release-frozen': o.releaseFrozen = need(); break;
|
|
82
|
+
case '--resume-cleanup': o.resumeCleanup = true; break;
|
|
83
|
+
default: throw new UsageError(`recover 不认得 ${a}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
o.only = [...new Set(o.only)];
|
|
87
|
+
if (o.only.length && o.fromGeneration === null) {
|
|
88
|
+
throw new UsageError('--only 只能配 --from-generation 使用(04-install.md §5.8.1)。');
|
|
89
|
+
}
|
|
90
|
+
// 🔴 互斥关系必须在这里拒绝,不能让两条语义在内核里撞车
|
|
91
|
+
// 🔴 `--rollback --from-generation <N>` 是 04-install.md §5.8 **写明的**组合
|
|
92
|
+
// (「事务已 completed 之后的复位」的正式拼法),不算冲突。
|
|
93
|
+
// 其余任意两项相加都没有定义好的语义。
|
|
94
|
+
const rollbackFromGen = o.mode === '--rollback' && o.fromGeneration !== null;
|
|
95
|
+
const exclusive = [
|
|
96
|
+
o.mode !== null && !rollbackFromGen && `${o.mode}`,
|
|
97
|
+
o.fromGeneration !== null && !rollbackFromGen && '--from-generation',
|
|
98
|
+
rollbackFromGen && '--rollback --from-generation',
|
|
99
|
+
o.resetGeneration !== null && '--reset-generation',
|
|
100
|
+
o.releaseFrozen !== null && '--release-frozen',
|
|
101
|
+
o.resumeCleanup && '--resume-cleanup',
|
|
102
|
+
].filter(Boolean);
|
|
103
|
+
if (exclusive.length > 1) {
|
|
104
|
+
throw new UsageError(
|
|
105
|
+
`recover 的这几个开关互斥,一次只能给一个:${exclusive.join(' 与 ')}。`
|
|
106
|
+
+ '它们各自是一条独立的出路,叠加起来没有定义好的语义(09-cli.md §1.1)。',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return o;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 现场画像 —— 只读,供分流判断与报告用。 */
|
|
113
|
+
export function survey(target) {
|
|
114
|
+
const P0 = layout(target);
|
|
115
|
+
if (!existsSync(P0.state)) return { present: false, generations: [], repairIntent: false, auditIntent: false };
|
|
116
|
+
const i = inspect(target);
|
|
117
|
+
const live = i.generations.filter((g) => g.phase !== 'completed');
|
|
118
|
+
const physCorrupt = [];
|
|
119
|
+
const adoptBad = [];
|
|
120
|
+
const unadoptBad = [];
|
|
121
|
+
let rollbackDirection = false;
|
|
122
|
+
for (const g of live) {
|
|
123
|
+
if (g.direction === 'rollback') rollbackDirection = true;
|
|
124
|
+
for (const [n, it] of Object.entries(g.items)) if (it.state === 'corrupt') physCorrupt.push(n);
|
|
125
|
+
for (const [n, s] of Object.entries(g.adopt)) if (s === 'assertion-corrupt') adoptBad.push(n);
|
|
126
|
+
for (const [n, s] of Object.entries(g.unadopt)) if (s === 'assertion-corrupt') unadoptBad.push(n);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
present: true, ...i, live, physCorrupt, adoptBad, unadoptBad, rollbackDirection,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 🔴 CLI 侧的分流守卫。**只做「规格说要拒绝、而内核不会拒绝」的那几格**;
|
|
135
|
+
* 内核已经拒绝的格子不在这里重复实现(重复一遍就有了第二份真相)。
|
|
136
|
+
*
|
|
137
|
+
* 目前只有一格:`--reinstall` 遇到 `cleanup_pending` 且无物理 corrupt。
|
|
138
|
+
*/
|
|
139
|
+
export function assertModeAllowed(mode, s) {
|
|
140
|
+
if (!s.present) return;
|
|
141
|
+
|
|
142
|
+
// 🔴 内核 `driveLive()` 把 `phase === 'cleanup_pending' && mode !== 'rollback'`
|
|
143
|
+
// 这一支排在**读 `items[*].state === 'corrupt'` 之前**,因此
|
|
144
|
+
// 「cleanup_pending 且存在物理 corrupt」这个现场会被直接 `runCleanup()` 吃掉:
|
|
145
|
+
// `--continue` 绕过了「拒绝物理 corrupt」,`--reinstall` 更是做了一次清理
|
|
146
|
+
// 而不是重装。09-cli.md §1.1 对这三条 flag 在物理 corrupt 下的规定分别是
|
|
147
|
+
// 拒绝 / 拒绝 / 唯一出路 —— 一个都没兑现。
|
|
148
|
+
// 修内核不在本块的文件边界内,所以这里整格拦下并转人工。
|
|
149
|
+
if (s.physCorrupt.length > 0 && s.live.some((g) => g.phase === 'cleanup_pending')) {
|
|
150
|
+
const e = new UsageError(
|
|
151
|
+
`现场是「\`phase = cleanup_pending\` 且存在物理 corrupt(${s.physCorrupt.join(',')})」——`
|
|
152
|
+
+ '本命令拒绝自动处置,转人工。\n'
|
|
153
|
+
+ ' 原因:内核 src/recover.mjs 的 driveLive() 会在读 items[*].state 之前先对 '
|
|
154
|
+
+ 'cleanup_pending 做清理续做,于是 09-cli.md §1.1 为这一格规定的'
|
|
155
|
+
+ '「--continue 拒绝 / --rollback 拒绝 / --reinstall 唯一出路」一条都不会生效。\n'
|
|
156
|
+
+ ' 这是**已知的内核缺口**,已写进交付汇报。',
|
|
157
|
+
);
|
|
158
|
+
// 🔴 落 5(「残留事务需 recover / 需人工」),不落 1 —— 这不是用法错误,
|
|
159
|
+
// 用户的命令写得没毛病,是现场处置不了。
|
|
160
|
+
e.exitCode = EXIT.NEEDS_RECOVER;
|
|
161
|
+
e.telemetryReason = 'journal-corrupt';
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (mode !== 'reinstall') return;
|
|
166
|
+
if (s.repairIntent) return; // repair intent 走 2b-1,本来就该 --reinstall
|
|
167
|
+
if (s.physCorrupt.length > 0) return; // 有物理 corrupt —— 正是它的用武之地
|
|
168
|
+
if (s.live.length === 0) return; // 没有未完成事务:内核会自己报「没有物理 corrupt」
|
|
169
|
+
const phases = [...new Set(s.live.map((g) => g.phase))].join(', ');
|
|
170
|
+
const e = new UsageError(
|
|
171
|
+
`--reinstall **仅物理 corrupt 可用**(09-cli.md §1.1),当前没有任何 `
|
|
172
|
+
+ `items[*].state = corrupt 的项(未完成事务的 phase:${phases})。\n`
|
|
173
|
+
+ ' 想续做请用 `--continue`,想回滚请用 `--rollback`。',
|
|
174
|
+
);
|
|
175
|
+
e.telemetryReason = 'unknown';
|
|
176
|
+
throw e;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function cmdRecover(ctx, argv, out) {
|
|
180
|
+
const o = parseRecoverArgs(argv);
|
|
181
|
+
|
|
182
|
+
if (o.releaseFrozen !== null) {
|
|
183
|
+
// 🔴 内核缺口,如实拒绝而不是假装做了。`src/ledger.mjs` 只有 `isGenerationFrozen()`,
|
|
184
|
+
// 没有「按 label 解冻」的导出,而 `frozen_attic` 的写入只能经由
|
|
185
|
+
// `derivePlan({ frozenAttic })` 的**整张 map** 语义 —— 那要一个完整事务。
|
|
186
|
+
// 在内核补上这个 API 之前,本命令不提供一个看起来成功、实际什么都没改的路径。
|
|
187
|
+
throw new UsageError(
|
|
188
|
+
'`--release-frozen` 还不可用:内核没有「按 label 解冻 attic」的导出'
|
|
189
|
+
+ '(src/ledger.mjs 只有 isGenerationFrozen,frozen_attic 的写入是整张 map 语义,需要一个事务)。\n'
|
|
190
|
+
+ ' 这是**已知的内核 API 缺口**,已写进交付汇报。不提供一个假装成功的路径。',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const tplan = planTargets({
|
|
195
|
+
clients: ctx.clients,
|
|
196
|
+
scope: ctx.scope,
|
|
197
|
+
home: ctx.home,
|
|
198
|
+
env: ctx.env,
|
|
199
|
+
projectRoot: ctx.projectRoot,
|
|
200
|
+
});
|
|
201
|
+
for (const w of tplan.warnings) out.warn(w);
|
|
202
|
+
assertPlanOk(tplan);
|
|
203
|
+
// recover 只对**已经有 .geoly 状态**的 target 有意义
|
|
204
|
+
const selected = tplan.selected.filter((t) => existsSync(layout(t.target).state));
|
|
205
|
+
const targets = selected.map((t) => t.target);
|
|
206
|
+
for (const s of tplan.skipped) out.note(`跳过 ${s.client}/${s.scope}:${s.reason}`);
|
|
207
|
+
if (targets.length === 0) {
|
|
208
|
+
out.line('没有任何 target 有 .geoly 状态目录 —— 无残留事务可处理。');
|
|
209
|
+
return out.emit('recover', { targets: [] }, EXIT.OK);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 🔴 §5.9:`--reset-generation` 的**作用域是单个 target**。
|
|
213
|
+
// 不同 target 的水位互不相干,一个 <N> 套不上所有 —— 多目标时必须点名。
|
|
214
|
+
// 🔴 判据是**本次命令选中的 target 总数**,不是「其中还剩几个有 .geoly 状态」。
|
|
215
|
+
// 按后者算的话,`--clients a,b` 里恰好只有一个建过状态目录时就会被放行 ——
|
|
216
|
+
// 而 §5.9 要的是「多目标时必须点名」,不是「碰巧只有一个有状态」。
|
|
217
|
+
if (o.resetGeneration !== null && tplan.selected.length !== 1) {
|
|
218
|
+
throw new UsageError(
|
|
219
|
+
`--reset-generation 的作用域是**单个 target**(04-install.md §5.9),`
|
|
220
|
+
+ `本次命令选中了 ${tplan.selected.length} 个:\n`
|
|
221
|
+
+ `${tplan.selected.map((t) => ` ${t.client}/${t.scope} ${t.target}`).join('\n')}\n`
|
|
222
|
+
+ ' 请用 --clients <单个> 收窄,或在 --project 下指向单一 target。'
|
|
223
|
+
+ '不同 target 的水位互不相干,同一个 <N> 套不上所有。',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const mode = o.mode ? o.mode.slice(2) : 'auto';
|
|
228
|
+
// 🔴 项目级 recover 收尾要重算 lockfile,而那需要回**已验签的历史快照**取
|
|
229
|
+
// `asset_sha256`。早先只有 `--reinstall` 拿 verifier,于是项目级的
|
|
230
|
+
// continue / rollback 一到收尾就必然抛 NetworkError(Codex 第二轮 P0-2)。
|
|
231
|
+
const needVerifier = o.mode === '--reinstall' || ctx.scope === 'project';
|
|
232
|
+
const verifier = needVerifier ? await getVerifier(ctx) : null;
|
|
233
|
+
const results = [];
|
|
234
|
+
|
|
235
|
+
withOrderedLocks(
|
|
236
|
+
{
|
|
237
|
+
baseFor: (p) => selected.find((t) => t.target === p)?.base ?? null,
|
|
238
|
+
projectRoot: ctx.scope === 'project' ? ctx.projectRoot : null,
|
|
239
|
+
targets,
|
|
240
|
+
},
|
|
241
|
+
({ targets: ordered }) => {
|
|
242
|
+
for (const t of ordered) {
|
|
243
|
+
const started = Date.now();
|
|
244
|
+
const before = survey(t.path);
|
|
245
|
+
try {
|
|
246
|
+
const r = runOne(ctx, t.path, { mode, o, before, verifier });
|
|
247
|
+
results.push({ target: t.path, ok: true, ms: Date.now() - started, ...r });
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const cls = classify(err);
|
|
250
|
+
results.push({
|
|
251
|
+
target: t.path, ok: false, ms: Date.now() - started,
|
|
252
|
+
error: err.message, exit_code: cls.code, reason: cls.reason, _err: err,
|
|
253
|
+
// 🔴 §5.5:**逐项报告,不自动选、不猜** —— 失败路径尤其需要它。
|
|
254
|
+
// 只给一句「拒绝」而不给现场,用户根本无从判断该选哪条出路。
|
|
255
|
+
report: safeInspect(t.path),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (ctx.record) {
|
|
263
|
+
for (const r of results) {
|
|
264
|
+
ctx.record({
|
|
265
|
+
kind: mode === 'rollback' ? 'rollback' : 'recover',
|
|
266
|
+
ms: r.ms,
|
|
267
|
+
reason: r.ok ? undefined : r.reason,
|
|
268
|
+
result: r.ok ? 'ok' : 'failed',
|
|
269
|
+
scope: ctx.scope,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// §5.5:逐项报告,**不自动选、不猜**
|
|
275
|
+
out.line(`recover 结果(mode=${mode},共 ${results.length} 个 target):`);
|
|
276
|
+
for (const r of results) {
|
|
277
|
+
out.line(` ${r.ok ? 'ok ' : 'failed '}${r.target}`);
|
|
278
|
+
if (r.ok) out.line(` ${r.outcome}${r.generation !== undefined ? `(第 ${r.generation} 代)` : ''}`);
|
|
279
|
+
else out.line(` ${r.error.split('\n').join('\n ')}`);
|
|
280
|
+
if (r.report) {
|
|
281
|
+
for (const g of r.report.generations) {
|
|
282
|
+
out.line(` 第 ${g.generation} 代 phase=${g.phase} direction=${g.direction ?? '-'}`);
|
|
283
|
+
for (const [n, it] of Object.entries(g.items)) {
|
|
284
|
+
out.line(` item ${n}: op=${it.op} state=${it.state} cleanup=${it.cleanup ?? '-'}`);
|
|
285
|
+
}
|
|
286
|
+
for (const [n, s] of Object.entries(g.adopt)) out.line(` adopt ${n}: ${s}`);
|
|
287
|
+
for (const [n, s] of Object.entries(g.unadopt)) out.line(` unadopt ${n}: ${s}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const failed = results.filter((r) => !r.ok);
|
|
293
|
+
let exit = EXIT.OK;
|
|
294
|
+
if (failed.length === results.length && failed.length > 0) exit = classify(failed[0]._err).code;
|
|
295
|
+
else if (failed.length > 0) exit = EXIT.PARTIAL;
|
|
296
|
+
|
|
297
|
+
const body = {
|
|
298
|
+
mode,
|
|
299
|
+
targets: results.map((r) => ({
|
|
300
|
+
// 🔴 标注挂在**每一个** target 对象上(§7:每一次相关输出都要重复标注)
|
|
301
|
+
annotations: annotations({ offline: ctx.offline }),
|
|
302
|
+
error: r.ok ? undefined : r.error,
|
|
303
|
+
exit_code: r.ok ? 0 : r.exit_code,
|
|
304
|
+
generation: r.generation,
|
|
305
|
+
ok: r.ok,
|
|
306
|
+
outcome: r.ok ? r.outcome : undefined,
|
|
307
|
+
report: r.report ?? undefined,
|
|
308
|
+
target: r.target,
|
|
309
|
+
})),
|
|
310
|
+
};
|
|
311
|
+
if (exit !== EXIT.OK && exit !== EXIT.PARTIAL) {
|
|
312
|
+
return out.emitError('recover', classify(failed[0]._err), failed[0]._err, body);
|
|
313
|
+
}
|
|
314
|
+
return out.emit('recover', body, exit);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** inspect 自己也可能因为现场损坏而抛;报告拿不到不该把原错误盖掉。 */
|
|
318
|
+
function safeInspect(target) {
|
|
319
|
+
try { return inspect(target); } catch { return null; }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function runOne(ctx, target, { mode, o, before, verifier }) {
|
|
323
|
+
const onLedgerChanged = makeLockfileHook(ctx, { verifier });
|
|
324
|
+
const common = { onLedgerChanged, keepGenerations: ctx.keepGenerations };
|
|
325
|
+
|
|
326
|
+
if (o.resetGeneration !== null) {
|
|
327
|
+
// §5.9:**作用域是单个 target** —— 多目标时必须配单个 --clients
|
|
328
|
+
resetGeneration(layout(target), o.resetGeneration);
|
|
329
|
+
return { outcome: 'generation-reset', generation: o.resetGeneration, report: inspect(target) };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (o.fromGeneration !== null) {
|
|
333
|
+
// §5.8:事务**早已 completed** 之后,按该代的 attic manifest 复位。
|
|
334
|
+
// 🔴 内核给的是**计划**(planFromGeneration),把它变成一个新的正向事务需要
|
|
335
|
+
// 逐项按 reverse_op 编排 —— 那一段(`runReverseTransaction`)内核没有导出。
|
|
336
|
+
// 先把计划算出来并如实报告,不假装已经复位。
|
|
337
|
+
const p = planFromGeneration(target, o.fromGeneration, { only: o.only.length ? o.only : undefined });
|
|
338
|
+
const e = new UsageError(
|
|
339
|
+
`第 ${o.fromGeneration} 代的复位**计划**已算出并通过 §5.8.1 三方比对,`
|
|
340
|
+
+ `选中 ${p.selection.items.length} 项:${p.selection.items.join(', ')}。\n`
|
|
341
|
+
+ ' 但把该计划执行成一个新的正向事务需要内核导出一个「按 reverse_op 编排」的入口,'
|
|
342
|
+
+ '目前 src/recover.mjs 只导出到 planFromGeneration 为止。\n'
|
|
343
|
+
+ ' 这是**已知的内核 API 缺口**,已写进交付汇报。不提供一个只做了一半的复位。',
|
|
344
|
+
);
|
|
345
|
+
e.exitCode = EXIT.USAGE;
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (o.resumeCleanup) {
|
|
350
|
+
// §5.6:正常情况下下一次运行会自动续做;这里是**显式**续做。
|
|
351
|
+
// 内核的 auto 分流本身就会把 cleanup_pending 推到 completed。
|
|
352
|
+
const r = kernelRecover(target, { mode: 'auto', ...common });
|
|
353
|
+
return { ...r, report: inspect(target) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 🔴 CLI 侧守卫:规格说要拒绝而内核不会拒绝的那一格
|
|
357
|
+
assertModeAllowed(mode, before);
|
|
358
|
+
|
|
359
|
+
if (mode === 'reinstall') return runReinstall(ctx, target, { common, before, verifier });
|
|
360
|
+
|
|
361
|
+
const r = kernelRecover(target, { ...common, mode });
|
|
362
|
+
return { ...r, report: inspect(target) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* §5.10 `--reinstall`。两个注入点缺任一个,内核都会**如实拒绝**自动 repair ——
|
|
367
|
+
* 不注入不等于放行,所以两个都要接上。
|
|
368
|
+
*
|
|
369
|
+
* · `assertSnapshotRetrievable(N)`:该快照必须能按 §02-6.1 的历史读取路径**取回并验签**;
|
|
370
|
+
* · `resolver(name) -> { artifact, dir }`:`--reinstall`「重新解析安装」的**主路径**。
|
|
371
|
+
* `dir` 必须是**已过完整验证链**的树 —— 内核只会再核一次摘要,不会替我们验签。
|
|
372
|
+
*
|
|
373
|
+
* 🔴 用 `withVerifiedArtifact()` 的**作用域版**:解包目录在 `kernelRecover` 返回后
|
|
374
|
+
* (无论成功还是抛错)自动清掉。裸 `verifyArtifact` + 「记得 dispose」在这条
|
|
375
|
+
* 有五个提前 return 的路径上必漏。
|
|
376
|
+
*/
|
|
377
|
+
function runReinstall(ctx, target, { common, before, verifier }) {
|
|
378
|
+
// 从**未完成事务的 journal** 里取「哪个 name 对应哪个 artifact、解析于哪张快照」。
|
|
379
|
+
// 🔴 取 `ledger_image.post`:`pre` 记的是上一次安装时的快照(同内核 bindSnapshot 的理由)。
|
|
380
|
+
const wanted = [];
|
|
381
|
+
const P0 = layout(target);
|
|
382
|
+
for (const g of listJournalGenerations(P0.journalDir)) {
|
|
383
|
+
const J = readJournal(layout(target, g).journal);
|
|
384
|
+
if (J.phase === 'completed') continue;
|
|
385
|
+
for (const [name, e] of Object.entries(J.ledger_image.post.entries)) {
|
|
386
|
+
if (e) wanted.push({ name, artifact: e.artifact, snapshot: e.snapshot });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 能从缓存取到并验通过的,预先物化成已验证的树;取不到的**跳过**——
|
|
391
|
+
// quarantine / quarantine-tx / attic 三种介质可能本来就够用(§5.10 的 ①②③)。
|
|
392
|
+
const fetched = [];
|
|
393
|
+
for (const w of wanted) {
|
|
394
|
+
let snap;
|
|
395
|
+
try { snap = readSnap(ctx, verifier, w.snapshot); } catch { continue; }
|
|
396
|
+
const rec = snap.artifacts.find((r) => r.id === w.artifact);
|
|
397
|
+
if (!rec) continue;
|
|
398
|
+
let bytes;
|
|
399
|
+
try { bytes = ctx.registry.fetchAsset(rec); } catch { continue; }
|
|
400
|
+
fetched.push({ ...w, record: rec, bytes });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const parent = join(target, STATE_DIR);
|
|
404
|
+
const run = (resolved) => {
|
|
405
|
+
const byName = new Map(resolved.map((x) => [x.name, x]));
|
|
406
|
+
return kernelRecover(target, {
|
|
407
|
+
...common,
|
|
408
|
+
mode: 'reinstall',
|
|
409
|
+
assertSnapshotRetrievable: (n) => { readSnap(ctx, verifier, n); },
|
|
410
|
+
artifactFor: (name) => byName.get(name)?.artifact,
|
|
411
|
+
resolver: (name) => {
|
|
412
|
+
const hit = byName.get(name);
|
|
413
|
+
if (!hit) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`repair:${name} 在 registry 里也取不到(缓存未命中,或它不在被绑定的那张快照里)——`
|
|
416
|
+
+ '枚举不出完整计划即拒绝自动执行,转人工',
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return { artifact: hit.artifact, dir: hit.dir };
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const nest = (rest, acc) => {
|
|
425
|
+
if (rest.length === 0) return run(acc);
|
|
426
|
+
const [h, ...t] = rest;
|
|
427
|
+
return withVerifiedArtifact({ bytes: h.bytes, record: h.record, parent }, (art) =>
|
|
428
|
+
nest(t, [...acc, { artifact: h.artifact, name: h.name, dir: art.dir }]));
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const r = nest(fetched, []);
|
|
432
|
+
return { ...r, report: inspect(target) };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function readSnap(ctx, verifier, n) {
|
|
436
|
+
const { bytes, bundle } = ctx.registry.fetchSnapshot(n);
|
|
437
|
+
return readHistoricalSnapshot({ bytes, bundle, verifier, expectSnapshot: n }).snapshot;
|
|
438
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// registry 读取层 —— 02-registry.md §6 的**取字节**那一半。
|
|
2
|
+
//
|
|
3
|
+
// 🔴 **M1 只有缓存适配器,没有网络客户端。** 两个理由,都不是「还没写」:
|
|
4
|
+
//
|
|
5
|
+
// ① `snapshot.resolveCurrent()` 是**同步**函数,它要求 `fetchTimestamp()` /
|
|
6
|
+
// `fetchSnapshot(N)` 同步返回 `{ bytes, bundle }`。内建 `fetch` 返回 Promise,
|
|
7
|
+
// 接不进去。把内核改成 async 是**内核 API 变更**,不在本块的文件边界内。
|
|
8
|
+
// ② 真实 registry 端点在 M1 还不存在。造一个假的出网路径,只会让
|
|
9
|
+
// 「`--offline` 有没有被绕过」这个问题**看起来**被回答了。
|
|
10
|
+
//
|
|
11
|
+
// 因此:**本模块一个字节都不出网**,`--offline` 与否走的是同一条码。
|
|
12
|
+
// 缓存未命中就是退出码 6,文案里如实说明是「M1 没有网络客户端」还是「离线未命中」。
|
|
13
|
+
// 这条缺口写进交付汇报。
|
|
14
|
+
//
|
|
15
|
+
// 缓存布局(内容寻址,摘要即身份):
|
|
16
|
+
// <cacheDir>/timestamp.json + timestamp.sigstore.json
|
|
17
|
+
// <cacheDir>/snapshots/<N>.json + <N>.sigstore.json
|
|
18
|
+
// <cacheDir>/assets/<sha256 的 hex> ← 🔴 按摘要命名:查得到就说明摘要对得上
|
|
19
|
+
//
|
|
20
|
+
// 🔴 命中缓存**也必须**走完同样的验签(02-registry.md §9.2):
|
|
21
|
+
// 本模块只负责**取字节**,一次校验都不做 —— 校验全在 `snapshot.resolveCurrent()`
|
|
22
|
+
// 与 `artifact.withVerifiedArtifact()` 里。把校验放进取字节层会诱使人写
|
|
23
|
+
// 「缓存命中就跳过」。
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { NetworkError, UsageError } from '../exit-codes.mjs';
|
|
28
|
+
import { parseStrict } from '../canonical-json.mjs';
|
|
29
|
+
|
|
30
|
+
/** 11-wire-contract.md §2:单个 JSON 文档 ≤ 8 MiB,**解析前先查文件大小**。 */
|
|
31
|
+
const MAX_JSON_BYTES = 8 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
function readCapped(path, what) {
|
|
34
|
+
const st = statSync(path);
|
|
35
|
+
if (st.size > MAX_JSON_BYTES) {
|
|
36
|
+
// 🔴 这**不是** NetworkError:文件明明在,只是超了 §2 的 8 MiB 上限。
|
|
37
|
+
// 报成 6 的后果很具体 —— `list` / `check` 的降级判据是「退出码 6 才可降级」,
|
|
38
|
+
// 于是一份超限(很可能被做过手脚)的本地缓存会被当成「网络取不到」而**静默放行**
|
|
39
|
+
// (Codex 第三轮 P0-4)。它属于 §6 第 1 条的「解析失败」。
|
|
40
|
+
throw new UsageError(
|
|
41
|
+
`${what} 超过 8 MiB(${st.size} 字节),拒绝解析(11-wire-contract.md §2:解析前先查文件大小)`,
|
|
42
|
+
{ telemetryReason: 'unknown' },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return readFileSync(path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function miss(what, path, offline) {
|
|
49
|
+
throw new NetworkError(
|
|
50
|
+
offline
|
|
51
|
+
? `${what} 未命中缓存(--offline):${path}\n`
|
|
52
|
+
+ ' 离线模式只用缓存;先在联网状态下取一次,或去掉 --offline。'
|
|
53
|
+
: `${what} 未命中缓存:${path}\n`
|
|
54
|
+
+ ' ⚠️ M1 的 CLI **没有网络客户端**(见 src/commands/registry.mjs 顶部注释)——\n'
|
|
55
|
+
+ ' 这一条不是「网络失败」,是「本地缓存里没有,而本版本还取不了」。',
|
|
56
|
+
{ telemetryReason: offline ? 'offline' : 'not-found' },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} o
|
|
62
|
+
* @param {string} o.cacheDir
|
|
63
|
+
* @param {boolean} o.offline
|
|
64
|
+
* @returns registry 读取器。所有方法**同步**,以便直接喂给 `resolveCurrent()`。
|
|
65
|
+
*/
|
|
66
|
+
export function createCacheRegistry({ cacheDir, offline = false }) {
|
|
67
|
+
const tsPath = join(cacheDir, 'timestamp.json');
|
|
68
|
+
const tsBundlePath = join(cacheDir, 'timestamp.sigstore.json');
|
|
69
|
+
const snapPath = (n) => join(cacheDir, 'snapshots', `${n}.json`);
|
|
70
|
+
const snapBundlePath = (n) => join(cacheDir, 'snapshots', `${n}.sigstore.json`);
|
|
71
|
+
const assetPath = (hex) => join(cacheDir, 'assets', hex);
|
|
72
|
+
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
cacheDir,
|
|
75
|
+
offline,
|
|
76
|
+
|
|
77
|
+
/** §6 第 1 步的输入。🔴 同步。 */
|
|
78
|
+
fetchTimestamp() {
|
|
79
|
+
if (!existsSync(tsPath)) miss('timestamp.json', tsPath, offline);
|
|
80
|
+
if (!existsSync(tsBundlePath)) miss('timestamp 的 sigstore bundle', tsBundlePath, offline);
|
|
81
|
+
return {
|
|
82
|
+
bytes: readCapped(tsPath, 'timestamp.json'),
|
|
83
|
+
bundle: parseStrict(readCapped(tsBundlePath, 'timestamp bundle').toString('utf8')),
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/** §6 第 4 步的输入,也是 §6.1 历史读取路径的输入。🔴 同步。 */
|
|
88
|
+
fetchSnapshot(n) {
|
|
89
|
+
const p = snapPath(n);
|
|
90
|
+
if (!existsSync(p)) miss(`快照 ${n}`, p, offline);
|
|
91
|
+
const bp = snapBundlePath(n);
|
|
92
|
+
if (!existsSync(bp)) miss(`快照 ${n} 的 sigstore bundle`, bp, offline);
|
|
93
|
+
return {
|
|
94
|
+
bytes: readCapped(p, `snapshot ${n}`),
|
|
95
|
+
bundle: parseStrict(readCapped(bp, `snapshot ${n} bundle`).toString('utf8')),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** 有没有这一份 —— 只用来**报告**,不得用来跳过校验。 */
|
|
100
|
+
hasSnapshot(n) { return existsSync(snapPath(n)) && existsSync(snapBundlePath(n)); },
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 取资产字节。🔴 按 `record.asset.sha256` 的 hex 寻址 ——
|
|
104
|
+
* 取回来之后仍然要过 `artifact.assertAssetBytes()`:
|
|
105
|
+
* 内容寻址证明的是「我们按这个名字存过」,不是「字节现在还对」。
|
|
106
|
+
*/
|
|
107
|
+
fetchAsset(record) {
|
|
108
|
+
const hex = record.asset.sha256.replace(/^sha256:/, '');
|
|
109
|
+
const p = assetPath(hex);
|
|
110
|
+
if (!existsSync(p)) miss(`资产 ${record.id}(${record.asset.file})`, p, offline);
|
|
111
|
+
const st = statSync(p);
|
|
112
|
+
if (st.size !== record.asset.size) {
|
|
113
|
+
// 大小不符是**完整性**问题,不是「取不到」—— 让它按 2 报,别混进 6
|
|
114
|
+
const e = new Error(
|
|
115
|
+
`资产 ${record.id} 的字节数是 ${st.size},快照记录说应为 ${record.asset.size}`,
|
|
116
|
+
);
|
|
117
|
+
e.name = 'IntegrityError';
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
return readFileSync(p);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|