dsh-m 0.1.0 → 0.2.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/README.en.md +104 -0
- package/README.md +61 -23
- package/docs/DESIGN.md +172 -0
- package/lib/cli.js +134 -63
- package/lib/client.js +1254 -217
- package/lib/core/dsh-cli.js +118 -10
- package/lib/core/host-api.js +285 -0
- package/lib/core/httpx.js +93 -36
- package/lib/core/installed.js +69 -4
- package/lib/core/market.js +481 -104
- package/lib/core/npm-integrity.js +141 -0
- package/lib/core/progress.js +113 -0
- package/lib/core/registry-check.js +111 -0
- package/lib/core/registry-controller.js +321 -0
- package/lib/core/registry.js +634 -98
- package/lib/core/versions.js +80 -9
- package/lib/host.js +22 -161
- package/lib/tools.js +56 -41
- package/package.json +4 -2
- package/registry.json +95 -7
- package/DESIGN.md +0 -140
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm integrity(DESIGN.md §3 基线扩展):pnpm lockfile v9 的包/version integrity 定位、
|
|
3
|
+
* 与 npm dist metadata 的比对断言、以及安装失败时的 best-effort manifest/lock 快照恢复。
|
|
4
|
+
* 只用 Node 内置代码;无法唯一定位 package/version/integrity 时 fail closed。
|
|
5
|
+
* 不声称 node_modules 与间接依赖已字节级回滚——这是 best-effort dependency rollback。
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, rename, rm, open, mkdir } from 'node:fs/promises';
|
|
8
|
+
import { constants as fsConstants } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
function unquote(key) {
|
|
11
|
+
if (key.length >= 2 && ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"')))) {
|
|
12
|
+
return key.slice(1, -1);
|
|
13
|
+
}
|
|
14
|
+
return key;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 解析 pnpm lockfile v9 的 packages 区块。结构(2 空格缩进层级):
|
|
18
|
+
* packages:
|
|
19
|
+
* name@1.2.3:
|
|
20
|
+
* resolution: {integrity: sha512-..., tarball: ...}
|
|
21
|
+
* '@scope/name@1.2.3(peer@2.0.0)':
|
|
22
|
+
* resolution: {...}
|
|
23
|
+
*/
|
|
24
|
+
function parsePackagesSection(lockText) {
|
|
25
|
+
const lines = lockText.split(/\r?\n/);
|
|
26
|
+
const versionLine = lines.find((l) => /^lockfileVersion:/.test(l.trim()));
|
|
27
|
+
const rawVersion = versionLine ? versionLine.split(':').slice(1).join(':').trim().replace(/^['"]|['"]$/g, '') : '';
|
|
28
|
+
if (rawVersion !== '9.0') {
|
|
29
|
+
throw new Error(`pnpm lockfile 版本不是 9.0(实际 ${rawVersion || '缺失'}),拒绝解析`);
|
|
30
|
+
}
|
|
31
|
+
const packagesIdx = lines.findIndex((l) => l.trim() === 'packages:');
|
|
32
|
+
if (packagesIdx === -1)
|
|
33
|
+
return new Map();
|
|
34
|
+
const entries = new Map();
|
|
35
|
+
let i = packagesIdx + 1;
|
|
36
|
+
while (i < lines.length) {
|
|
37
|
+
const line = lines[i];
|
|
38
|
+
const indent = line.length - line.trimStart().length;
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (trimmed === '') {
|
|
41
|
+
i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (indent < 2 || (indent === 0 && trimmed !== ''))
|
|
45
|
+
break;
|
|
46
|
+
if (indent !== 2 || !trimmed.endsWith(':')) {
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const key = unquote(trimmed.slice(0, -1));
|
|
51
|
+
if (entries.has(key))
|
|
52
|
+
throw new Error(`pnpm lockfile packages 键重复: ${key}`);
|
|
53
|
+
// 读取该条目区块直到下一条同级键
|
|
54
|
+
let integrity = null;
|
|
55
|
+
i += 1;
|
|
56
|
+
while (i < lines.length) {
|
|
57
|
+
const inner = lines[i];
|
|
58
|
+
const innerIndent = inner.length - inner.trimStart().length;
|
|
59
|
+
if (inner.trim() !== '' && innerIndent <= 2)
|
|
60
|
+
break;
|
|
61
|
+
const m = /(?:^|[,{]\s*)integrity:\s*([^,}\s'"]+)/.exec(inner);
|
|
62
|
+
if (m && integrity === null)
|
|
63
|
+
integrity = m[1];
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
entries.set(key, { key, integrity });
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 定位 lockfile 中 pkg@version 的 resolution.integrity。
|
|
72
|
+
* peer suffix(`(peer@x)`)仅在其剥离后唯一时接受;多个候选 integrity 不一致、
|
|
73
|
+
* lockfile 版本不支持、键重复等一律 throw(fail closed)。找不到返回 null。
|
|
74
|
+
*/
|
|
75
|
+
export function readPnpmLockIntegrity(lockText, pkg, version) {
|
|
76
|
+
const entries = parsePackagesSection(lockText);
|
|
77
|
+
const target = `${pkg}@${version}`;
|
|
78
|
+
const matches = [...entries.values()].filter((entry) => {
|
|
79
|
+
if (!entry.key.startsWith(target))
|
|
80
|
+
return false;
|
|
81
|
+
const rest = entry.key.slice(target.length);
|
|
82
|
+
return rest === '' || rest.startsWith('(');
|
|
83
|
+
});
|
|
84
|
+
if (matches.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
const integrities = new Set(matches.map((m) => m.integrity));
|
|
87
|
+
if (integrities.size > 1 || (matches.length > 1 && integrities.has(null))) {
|
|
88
|
+
throw new Error(`无法唯一定位 ${target} 的 integrity(${matches.length} 个候选解析结果不一致),fail closed`);
|
|
89
|
+
}
|
|
90
|
+
return matches[0].integrity;
|
|
91
|
+
}
|
|
92
|
+
/** 比对 npm dist integrity 与 lockfile 实际值:缺失或不一致都 throw。 */
|
|
93
|
+
export function assertNpmIntegrity(expected, actual, pkg, version) {
|
|
94
|
+
if (!expected)
|
|
95
|
+
throw new Error(`npm metadata 缺少 dist integrity:${pkg}@${version}`);
|
|
96
|
+
if (!actual)
|
|
97
|
+
throw new Error(`pnpm lockfile 中找不到 ${pkg}@${version} 的 resolution.integrity`);
|
|
98
|
+
if (actual !== expected) {
|
|
99
|
+
throw new Error(`integrity 不一致:${pkg}@${version} 期望 ${expected},lockfile 实际 ${actual}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function atomicWriteFile(path, bytes) {
|
|
103
|
+
await mkdir(dirname(path), { recursive: true });
|
|
104
|
+
const tmp = `${path}.restore-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
105
|
+
const fh = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
106
|
+
try {
|
|
107
|
+
await fh.write(bytes);
|
|
108
|
+
await fh.sync();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await fh.close().catch(() => undefined);
|
|
112
|
+
}
|
|
113
|
+
await rm(path, { force: true });
|
|
114
|
+
await rename(tmp, path);
|
|
115
|
+
}
|
|
116
|
+
/** 记录 profile 关键文件的字节快照(package.json / pnpm-lock.yaml / pnpm-workspace.yaml)。 */
|
|
117
|
+
export async function snapshotFiles(paths) {
|
|
118
|
+
return Promise.all(paths.map(async (path) => {
|
|
119
|
+
try {
|
|
120
|
+
const bytes = await readFile(path);
|
|
121
|
+
return { path, existed: true, bytes };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { path, existed: false, bytes: null };
|
|
125
|
+
}
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 恢复快照:原先存在的文件原样写回(原子 rename);原先不存在的删除安装过程中
|
|
130
|
+
* 新生成的文件。恢复动作本身失败由调用方汇总报告。
|
|
131
|
+
*/
|
|
132
|
+
export async function restoreSnapshots(snapshots) {
|
|
133
|
+
for (const snap of snapshots) {
|
|
134
|
+
if (snap.existed && snap.bytes !== null) {
|
|
135
|
+
await atomicWriteFile(snap.path, snap.bytes);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
await rm(snap.path, { force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pnpm `--reporter=ndjson` 进度解析器。移植自 skillhub ndjson.ts(零依赖)。
|
|
3
|
+
* stdout 每行一个 JSON 对象;人类可读的回退行由 spawn 层处理。
|
|
4
|
+
*/
|
|
5
|
+
export function emptyProgress() {
|
|
6
|
+
return {
|
|
7
|
+
phase: null,
|
|
8
|
+
done: 0,
|
|
9
|
+
total: null,
|
|
10
|
+
currentPackage: null,
|
|
11
|
+
downloaded: null,
|
|
12
|
+
size: null,
|
|
13
|
+
seen: false,
|
|
14
|
+
error: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function createProgressTracker() {
|
|
18
|
+
const snap = emptyProgress();
|
|
19
|
+
const seenPackages = new Set();
|
|
20
|
+
function dedupe(packageId) {
|
|
21
|
+
if (typeof packageId !== 'string' || packageId === '')
|
|
22
|
+
return;
|
|
23
|
+
if (!seenPackages.has(packageId)) {
|
|
24
|
+
seenPackages.add(packageId);
|
|
25
|
+
snap.done += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function feed(line) {
|
|
29
|
+
let event;
|
|
30
|
+
try {
|
|
31
|
+
event = JSON.parse(line);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (typeof event !== 'object' || event === null)
|
|
37
|
+
return;
|
|
38
|
+
const msg = event;
|
|
39
|
+
const name = msg.name;
|
|
40
|
+
if (typeof name !== 'string')
|
|
41
|
+
return;
|
|
42
|
+
if (name === 'pnpm:stage') {
|
|
43
|
+
const stage = msg.stage;
|
|
44
|
+
if (stage === 'resolution_started')
|
|
45
|
+
snap.phase = 'resolving';
|
|
46
|
+
else if (stage === 'resolution_done')
|
|
47
|
+
snap.phase = 'downloading';
|
|
48
|
+
else if (stage === 'importing_started' || stage === 'importing_done')
|
|
49
|
+
snap.phase = 'linking';
|
|
50
|
+
snap.seen = true;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (name === 'pnpm:progress') {
|
|
54
|
+
snap.seen = true;
|
|
55
|
+
const status = msg.status;
|
|
56
|
+
if (status === 'resolved') {
|
|
57
|
+
if (snap.phase === null)
|
|
58
|
+
snap.phase = 'resolving';
|
|
59
|
+
dedupe(msg.packageId);
|
|
60
|
+
}
|
|
61
|
+
else if (status === 'fetched' || status === 'found_in_store') {
|
|
62
|
+
snap.phase = 'downloading';
|
|
63
|
+
snap.currentPackage = typeof msg.packageId === 'string' ? msg.packageId : snap.currentPackage;
|
|
64
|
+
dedupe(msg.packageId);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (name === 'pnpm:fetching-progress') {
|
|
69
|
+
snap.seen = true;
|
|
70
|
+
snap.phase = 'downloading';
|
|
71
|
+
if (typeof msg.packageId === 'string')
|
|
72
|
+
snap.currentPackage = msg.packageId;
|
|
73
|
+
if (typeof msg.size === 'number')
|
|
74
|
+
snap.size = msg.size;
|
|
75
|
+
if (typeof msg.downloaded === 'number')
|
|
76
|
+
snap.downloaded = msg.downloaded;
|
|
77
|
+
dedupe(msg.packageId);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (name === 'pnpm:lifecycle') {
|
|
81
|
+
snap.seen = true;
|
|
82
|
+
snap.phase = 'building';
|
|
83
|
+
const wd = typeof msg.wd === 'string' ? msg.wd : '';
|
|
84
|
+
const dep = typeof msg.depPath === 'string' ? msg.depPath : '';
|
|
85
|
+
const base = wd.split(/[\\/]/).filter(Boolean).pop();
|
|
86
|
+
snap.currentPackage = base ?? (dep !== '' ? dep : snap.currentPackage);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (name === 'pnpm:stats') {
|
|
90
|
+
if (msg.added !== undefined || msg.removed !== undefined)
|
|
91
|
+
snap.phase = 'linking';
|
|
92
|
+
snap.seen = true;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (name === 'pnpm' && msg.level === 'error') {
|
|
96
|
+
const err = (msg.err ?? {});
|
|
97
|
+
const message = typeof err.message === 'string' ? err.message : '';
|
|
98
|
+
if (message !== '')
|
|
99
|
+
snap.error = message.slice(0, 400);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function reset() {
|
|
103
|
+
seenPackages.clear();
|
|
104
|
+
Object.assign(snap, emptyProgress());
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
get snapshot() {
|
|
108
|
+
return { ...snap };
|
|
109
|
+
},
|
|
110
|
+
feed,
|
|
111
|
+
reset,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { isReachable } from './httpx.js';
|
|
2
|
+
import { githubLatestTag, npmLatest } from './versions.js';
|
|
3
|
+
const MAX_ISSUES = 100;
|
|
4
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
5
|
+
const MAX_CONCURRENCY = 8;
|
|
6
|
+
const DEFAULT_DEADLINE_MS = 60_000;
|
|
7
|
+
const FIELD_ORDER = ['npm', 'github', 'homepage', 'icon'];
|
|
8
|
+
function normalizeConcurrency(raw) {
|
|
9
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0)
|
|
10
|
+
return DEFAULT_CONCURRENCY;
|
|
11
|
+
return Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(raw)));
|
|
12
|
+
}
|
|
13
|
+
export function defaultRegistryCheckDeps() {
|
|
14
|
+
return {
|
|
15
|
+
npmLatest: (pkg, timeoutMs, signal) => npmLatest(pkg, timeoutMs, signal),
|
|
16
|
+
githubLatestTag: (repo, timeoutMs, signal) => githubLatestTag(repo, timeoutMs, signal),
|
|
17
|
+
reachable: (url, timeoutMs, signal) => isReachable(url, timeoutMs, signal),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function collectProbes(registry, deps) {
|
|
21
|
+
const probes = [];
|
|
22
|
+
registry.plugins.forEach((entry, entryIndex) => {
|
|
23
|
+
const push = (field, run) => {
|
|
24
|
+
probes.push({ id: entry.id, field, entryIndex, fieldIndex: FIELD_ORDER.indexOf(field), run });
|
|
25
|
+
};
|
|
26
|
+
if (entry.source === 'npm' && entry.npm) {
|
|
27
|
+
push('npm', (t, s) => deps.npmLatest(entry.npm, t, s));
|
|
28
|
+
}
|
|
29
|
+
if (entry.github) {
|
|
30
|
+
push('github', (t, s) => deps.githubLatestTag(entry.github, t, s));
|
|
31
|
+
}
|
|
32
|
+
if (entry.homepage) {
|
|
33
|
+
push('homepage', (t, s) => deps.reachable(entry.homepage, t, s));
|
|
34
|
+
}
|
|
35
|
+
if (entry.icon) {
|
|
36
|
+
push('icon', (t, s) => deps.reachable(entry.icon, t, s));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return probes;
|
|
40
|
+
}
|
|
41
|
+
/** probe 收敛:deadline 内未 resolve/reject 一律按 timeout 统计,不永久等待。 */
|
|
42
|
+
function raceProbe(task, deadlineAt) {
|
|
43
|
+
let timer;
|
|
44
|
+
const timeoutP = new Promise((resolve) => {
|
|
45
|
+
timer = setTimeout(() => resolve({ kind: 'timeout' }), Math.max(1, deadlineAt - Date.now()));
|
|
46
|
+
});
|
|
47
|
+
return Promise.race([task, timeoutP]).finally(() => {
|
|
48
|
+
if (timer)
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export async function checkRegistryEntries(registry, options = {}, deps = defaultRegistryCheckDeps()) {
|
|
53
|
+
const timeoutMs = options.timeoutMs ?? 20_000;
|
|
54
|
+
const concurrency = normalizeConcurrency(options.concurrency);
|
|
55
|
+
const deadlineMs = options.deadlineMs ?? DEFAULT_DEADLINE_MS;
|
|
56
|
+
const signal = options.signal;
|
|
57
|
+
const deadlineAt = Date.now() + deadlineMs;
|
|
58
|
+
const probes = collectProbes(registry, deps);
|
|
59
|
+
const records = [];
|
|
60
|
+
let checked = 0;
|
|
61
|
+
let passed = 0;
|
|
62
|
+
let failed = 0;
|
|
63
|
+
let aborted = false;
|
|
64
|
+
const onAbort = () => {
|
|
65
|
+
aborted = true;
|
|
66
|
+
};
|
|
67
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
68
|
+
const record = (spec, message) => {
|
|
69
|
+
records.push({ entryIndex: spec.entryIndex, fieldIndex: spec.fieldIndex, issue: { id: spec.id, field: spec.field, message } });
|
|
70
|
+
};
|
|
71
|
+
let next = 0;
|
|
72
|
+
const worker = async () => {
|
|
73
|
+
for (;;) {
|
|
74
|
+
if (aborted || Date.now() >= deadlineAt)
|
|
75
|
+
return;
|
|
76
|
+
const index = next;
|
|
77
|
+
if (index >= probes.length)
|
|
78
|
+
return;
|
|
79
|
+
next += 1;
|
|
80
|
+
const spec = probes[index];
|
|
81
|
+
checked += 1;
|
|
82
|
+
const budget = Math.max(1, Math.min(timeoutMs, deadlineAt - Date.now()));
|
|
83
|
+
const outcome = await raceProbe(spec.run(budget, signal).then((value) => ({ kind: 'value', value }), (error) => ({ kind: 'error', error })), deadlineAt);
|
|
84
|
+
if (outcome.kind === 'timeout') {
|
|
85
|
+
failed += 1;
|
|
86
|
+
record(spec, 'probe 在 deadline 前未收敛');
|
|
87
|
+
}
|
|
88
|
+
else if (outcome.kind === 'error') {
|
|
89
|
+
failed += 1;
|
|
90
|
+
record(spec, outcome.error instanceof Error ? outcome.error.message : String(outcome.error));
|
|
91
|
+
}
|
|
92
|
+
else if (spec.field === 'homepage' || spec.field === 'icon') {
|
|
93
|
+
if (outcome.value === true)
|
|
94
|
+
passed += 1;
|
|
95
|
+
else {
|
|
96
|
+
failed += 1;
|
|
97
|
+
record(spec, 'URL 不可达');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
passed += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const size = Math.min(concurrency, probes.length);
|
|
106
|
+
await Promise.all(Array.from({ length: size === 0 ? 1 : size }, worker));
|
|
107
|
+
signal?.removeEventListener('abort', onAbort);
|
|
108
|
+
records.sort((a, b) => (a.entryIndex - b.entryIndex) || (a.fieldIndex - b.fieldIndex));
|
|
109
|
+
const issues = records.slice(0, MAX_ISSUES).map((r) => r.issue);
|
|
110
|
+
return { checked, passed, failed, issues, truncated: records.length > MAX_ISSUES };
|
|
111
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry settings controller(DESIGN.md §4):active config / configured address /
|
|
3
|
+
* pending / config phase 与真实 active registry 的分离。
|
|
4
|
+
* - 外部 settings 写入进入同一串行 queue,异步加载用 generation fence 防旧结果覆盖新结果;
|
|
5
|
+
* - `rejected` 只属于 config phase,不污染 loaded 的真实 source/status;
|
|
6
|
+
* - apply:candidate 校验成功 → store.update → 原子切换 active → commitActiveSource;
|
|
7
|
+
* - 最近一次 accepted address 通过 host cache metadata 跨重启保存,无效持久化值自动回滚。
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { cacheDir } from './env.js';
|
|
12
|
+
import { commitActiveSource, loadDefaultRegistry, loadRegistry, loadRegistryCandidate, parseRegistryAddress, } from './registry.js';
|
|
13
|
+
export class RegistryConfigError extends Error {
|
|
14
|
+
errors;
|
|
15
|
+
constructor(message, errors = []) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'RegistryConfigError';
|
|
18
|
+
this.errors = errors;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const ACCEPTED_SOURCE_FILE = 'active-source.json';
|
|
22
|
+
/** 读取 host accepted-source metadata(损坏/缺失返回 null)。 */
|
|
23
|
+
export async function readAcceptedSourceMetadata() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(await readFile(join(cacheDir(), 'host', ACCEPTED_SOURCE_FILE), 'utf8'));
|
|
26
|
+
if (!raw || typeof raw !== 'object')
|
|
27
|
+
return null;
|
|
28
|
+
if (raw.version !== 1 || raw.namespace !== 'host')
|
|
29
|
+
return null;
|
|
30
|
+
if (typeof raw.configuredAddress !== 'string' || typeof raw.cacheKey !== 'string')
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof raw.savedAt !== 'string')
|
|
33
|
+
return null;
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function trimAddress(raw) {
|
|
41
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
42
|
+
}
|
|
43
|
+
function placeholderLoaded(configuredAddress) {
|
|
44
|
+
return {
|
|
45
|
+
configuredAddress,
|
|
46
|
+
activeAddress: null,
|
|
47
|
+
source: configuredAddress === '' ? 'default-cache' : 'custom-unavailable',
|
|
48
|
+
status: 'unavailable',
|
|
49
|
+
isDefault: configuredAddress === '',
|
|
50
|
+
stale: false,
|
|
51
|
+
fetchedAt: null,
|
|
52
|
+
errors: [],
|
|
53
|
+
count: 0,
|
|
54
|
+
registry: { version: 1, plugins: [] },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function errorMessage(err) {
|
|
58
|
+
return err instanceof Error ? err.message : String(err);
|
|
59
|
+
}
|
|
60
|
+
export function createRegistryController(initial = {}) {
|
|
61
|
+
// config 是共享对象:host.ts 把它传给 tools/API,controller 原地更新字段实现 live 生效
|
|
62
|
+
const config = { ...initial };
|
|
63
|
+
let activeConfigAddress = trimAddress(initial.registryUrl);
|
|
64
|
+
let loaded = placeholderLoaded(activeConfigAddress);
|
|
65
|
+
let pendingAddress = null;
|
|
66
|
+
let configStatus = 'loading';
|
|
67
|
+
let configErrors = [];
|
|
68
|
+
let warnings = [];
|
|
69
|
+
let generation = 0;
|
|
70
|
+
let disposed = false;
|
|
71
|
+
let store = null;
|
|
72
|
+
let queue = Promise.resolve();
|
|
73
|
+
let bootstrapPromise = null;
|
|
74
|
+
let lastSelfWrite = null;
|
|
75
|
+
let unwatch = null;
|
|
76
|
+
function enqueue(task) {
|
|
77
|
+
if (disposed)
|
|
78
|
+
return Promise.reject(new Error('registry controller 已 disposed'));
|
|
79
|
+
const run = queue.then(() => {
|
|
80
|
+
const gen = ++generation;
|
|
81
|
+
return task(gen);
|
|
82
|
+
});
|
|
83
|
+
queue = run.then(() => undefined, () => undefined);
|
|
84
|
+
return run;
|
|
85
|
+
}
|
|
86
|
+
function adopt(gen, nextRegistryUrl, candidate) {
|
|
87
|
+
if (disposed || gen !== generation)
|
|
88
|
+
return false;
|
|
89
|
+
config.registryUrl = nextRegistryUrl;
|
|
90
|
+
activeConfigAddress = candidate.configuredAddress;
|
|
91
|
+
loaded = candidate;
|
|
92
|
+
pendingAddress = null;
|
|
93
|
+
configErrors = [];
|
|
94
|
+
configStatus = 'ready';
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
function snapshotInternal() {
|
|
98
|
+
return {
|
|
99
|
+
configuredAddress: trimAddress(config.registryUrl),
|
|
100
|
+
activeConfigAddress,
|
|
101
|
+
pendingAddress,
|
|
102
|
+
configStatus,
|
|
103
|
+
configErrors: [...configErrors],
|
|
104
|
+
warnings: [...warnings],
|
|
105
|
+
loaded,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function rollbackPersist(value) {
|
|
109
|
+
if (!store)
|
|
110
|
+
return false;
|
|
111
|
+
lastSelfWrite = value;
|
|
112
|
+
try {
|
|
113
|
+
await store.update({ registryUrl: value });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
warnings.push(`回滚持久化配置失败:${errorMessage(err)};将在下次启动再次尝试`);
|
|
118
|
+
lastSelfWrite = null;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function candidateFor(rawAddress, signal) {
|
|
123
|
+
return loadRegistryCandidate({ ...config, registryUrl: rawAddress }, { namespace: 'host', signal });
|
|
124
|
+
}
|
|
125
|
+
const bootstrap = async () => {
|
|
126
|
+
await enqueue(async (gen) => {
|
|
127
|
+
const source = store ? store.get() : initial;
|
|
128
|
+
config.timeoutMs = source.timeoutMs;
|
|
129
|
+
config.cacheTtlMin = source.cacheTtlMin;
|
|
130
|
+
const raw = trimAddress(source.registryUrl);
|
|
131
|
+
const attempt = await loadRegistry({ ...config, registryUrl: raw }, { namespace: 'host' });
|
|
132
|
+
if (disposed || gen !== generation)
|
|
133
|
+
return;
|
|
134
|
+
if (attempt.status !== 'unavailable') {
|
|
135
|
+
adopt(gen, raw, attempt);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// 持久化 custom 不可达:先试 accepted address(含其 cache),无 accepted 则默认
|
|
139
|
+
configErrors = [...attempt.errors];
|
|
140
|
+
const accepted = await readAcceptedSourceMetadata();
|
|
141
|
+
const recoverAddress = accepted && accepted.configuredAddress !== raw ? accepted.configuredAddress : '';
|
|
142
|
+
const recovered = await loadRegistry({ ...config, registryUrl: recoverAddress }, { namespace: 'host', force: true });
|
|
143
|
+
if (disposed || gen !== generation)
|
|
144
|
+
return;
|
|
145
|
+
if (recovered.status !== 'unavailable') {
|
|
146
|
+
adopt(gen, recoverAddress, recovered);
|
|
147
|
+
configStatus = 'rejected';
|
|
148
|
+
configErrors = [...attempt.errors];
|
|
149
|
+
pendingAddress = null;
|
|
150
|
+
warnings.push(`持久化 registry 地址不可用,已回滚到${recoverAddress === '' ? '默认清单' : recoverAddress}`);
|
|
151
|
+
await rollbackPersist(recoverAddress);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// 连恢复都失败:接受 unavailable 现实,config 标记 rejected
|
|
155
|
+
loaded = recovered;
|
|
156
|
+
activeConfigAddress = recovered.configuredAddress;
|
|
157
|
+
config.registryUrl = recoverAddress;
|
|
158
|
+
configStatus = 'rejected';
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
function startBootstrap() {
|
|
163
|
+
if (!bootstrapPromise) {
|
|
164
|
+
bootstrapPromise = bootstrap().catch((err) => {
|
|
165
|
+
if (!disposed) {
|
|
166
|
+
configStatus = 'unavailable';
|
|
167
|
+
configErrors = [errorMessage(err)];
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return bootstrapPromise;
|
|
172
|
+
}
|
|
173
|
+
function handleExternalWatch(next) {
|
|
174
|
+
if (disposed)
|
|
175
|
+
return Promise.resolve();
|
|
176
|
+
const raw = trimAddress(next.registryUrl);
|
|
177
|
+
if (lastSelfWrite !== null && raw === lastSelfWrite) {
|
|
178
|
+
lastSelfWrite = null;
|
|
179
|
+
return Promise.resolve();
|
|
180
|
+
}
|
|
181
|
+
if (raw === trimAddress(config.registryUrl) && configStatus === 'ready')
|
|
182
|
+
return Promise.resolve();
|
|
183
|
+
return enqueue(async (gen) => {
|
|
184
|
+
let candidate;
|
|
185
|
+
try {
|
|
186
|
+
candidate = await candidateFor(raw);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
candidate = { ...placeholderLoaded(raw), errors: [errorMessage(err)] };
|
|
190
|
+
}
|
|
191
|
+
if (disposed || gen !== generation)
|
|
192
|
+
return;
|
|
193
|
+
if (candidate.status !== 'unavailable') {
|
|
194
|
+
adopt(gen, raw, candidate);
|
|
195
|
+
const nextConfig = { ...config, registryUrl: raw, timeoutMs: next.timeoutMs, cacheTtlMin: next.cacheTtlMin };
|
|
196
|
+
config.timeoutMs = nextConfig.timeoutMs;
|
|
197
|
+
config.cacheTtlMin = nextConfig.cacheTtlMin;
|
|
198
|
+
try {
|
|
199
|
+
const commit = await commitActiveSource(parseRegistryAddress(raw), 'host');
|
|
200
|
+
if (commit.warning)
|
|
201
|
+
warnings.push(commit.warning);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* commit 失败不改变已采纳的 active */
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// 外部无效值:保持旧 active,回滚持久化值
|
|
209
|
+
configStatus = 'rejected';
|
|
210
|
+
configErrors = [...candidate.errors];
|
|
211
|
+
pendingAddress = raw;
|
|
212
|
+
const rolledBack = await rollbackPersist(trimAddress(config.registryUrl));
|
|
213
|
+
if (rolledBack)
|
|
214
|
+
pendingAddress = null;
|
|
215
|
+
}).then(() => undefined, () => undefined);
|
|
216
|
+
}
|
|
217
|
+
function attachStore(s) {
|
|
218
|
+
if (disposed)
|
|
219
|
+
throw new Error('registry controller 已 disposed');
|
|
220
|
+
store = s;
|
|
221
|
+
unwatch?.();
|
|
222
|
+
unwatch = s.watch((next) => {
|
|
223
|
+
void handleExternalWatch(next);
|
|
224
|
+
});
|
|
225
|
+
void startBootstrap();
|
|
226
|
+
}
|
|
227
|
+
function ensureReady(opts) {
|
|
228
|
+
void opts;
|
|
229
|
+
return startBootstrap();
|
|
230
|
+
}
|
|
231
|
+
async function snapshot(opts = {}) {
|
|
232
|
+
await startBootstrap();
|
|
233
|
+
if (opts.force) {
|
|
234
|
+
await enqueue(async (gen) => {
|
|
235
|
+
const attempt = await loadRegistry(config, { namespace: 'host', force: true, signal: opts.signal });
|
|
236
|
+
if (disposed || gen !== generation)
|
|
237
|
+
return;
|
|
238
|
+
loaded = attempt;
|
|
239
|
+
activeConfigAddress = attempt.configuredAddress;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return snapshotInternal();
|
|
243
|
+
}
|
|
244
|
+
function loadDefault(opts = {}) {
|
|
245
|
+
return loadDefaultRegistry(config, { namespace: 'host', force: opts?.force, signal: opts?.signal });
|
|
246
|
+
}
|
|
247
|
+
function apply(rawAddress, opts = {}) {
|
|
248
|
+
return enqueue(async (gen) => {
|
|
249
|
+
const trimmed = String(rawAddress ?? '').trim();
|
|
250
|
+
let address;
|
|
251
|
+
try {
|
|
252
|
+
address = parseRegistryAddress(trimmed);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
configStatus = 'rejected';
|
|
256
|
+
pendingAddress = trimmed || null;
|
|
257
|
+
configErrors = [errorMessage(err)];
|
|
258
|
+
throw new RegistryConfigError(errorMessage(err), [errorMessage(err)]);
|
|
259
|
+
}
|
|
260
|
+
let candidate;
|
|
261
|
+
try {
|
|
262
|
+
candidate = await candidateFor(trimmed, opts.signal);
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
if (disposed || gen !== generation)
|
|
266
|
+
return snapshotInternal();
|
|
267
|
+
configStatus = 'rejected';
|
|
268
|
+
pendingAddress = trimmed || null;
|
|
269
|
+
configErrors = [errorMessage(err)];
|
|
270
|
+
throw new RegistryConfigError(errorMessage(err), [errorMessage(err)]);
|
|
271
|
+
}
|
|
272
|
+
if (disposed || gen !== generation)
|
|
273
|
+
return snapshotInternal();
|
|
274
|
+
if (candidate.status === 'unavailable') {
|
|
275
|
+
configStatus = 'rejected';
|
|
276
|
+
pendingAddress = trimmed || null;
|
|
277
|
+
configErrors = [...candidate.errors];
|
|
278
|
+
throw new RegistryConfigError('registry 地址校验失败', candidate.errors);
|
|
279
|
+
}
|
|
280
|
+
if (store) {
|
|
281
|
+
lastSelfWrite = trimmed;
|
|
282
|
+
try {
|
|
283
|
+
await store.update({ registryUrl: trimmed });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
lastSelfWrite = null;
|
|
287
|
+
configStatus = 'pending';
|
|
288
|
+
pendingAddress = trimmed;
|
|
289
|
+
configErrors = [errorMessage(err)];
|
|
290
|
+
throw new RegistryConfigError(`配置校验成功但写入设置失败:${errorMessage(err)}`, [errorMessage(err)]);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
adopt(gen, trimmed, candidate);
|
|
294
|
+
try {
|
|
295
|
+
const commit = await commitActiveSource(address, 'host');
|
|
296
|
+
if (commit.warning)
|
|
297
|
+
warnings.push(commit.warning);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
warnings.push(`accepted-source 提交异常:${errorMessage(err)}`);
|
|
301
|
+
}
|
|
302
|
+
return snapshotInternal();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function dispose() {
|
|
306
|
+
disposed = true;
|
|
307
|
+
unwatch?.();
|
|
308
|
+
unwatch = null;
|
|
309
|
+
store = null;
|
|
310
|
+
}
|
|
311
|
+
const controller = {
|
|
312
|
+
config,
|
|
313
|
+
attachStore,
|
|
314
|
+
ensureReady,
|
|
315
|
+
snapshot,
|
|
316
|
+
loadDefault,
|
|
317
|
+
apply,
|
|
318
|
+
dispose,
|
|
319
|
+
};
|
|
320
|
+
return controller;
|
|
321
|
+
}
|