dsh-m 0.4.2 → 0.5.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 +12 -0
- package/README.md +13 -1
- package/docs/DESIGN.md +30 -0
- package/lib/cli.js +94 -20
- package/lib/client.js +282 -17
- package/lib/core/community-adapter.js +181 -0
- package/lib/core/community.js +433 -0
- package/lib/core/host-api.js +53 -19
- package/lib/core/httpx.js +38 -0
- package/lib/core/install-guard.js +308 -0
- package/lib/core/market.js +678 -55
- package/lib/core/profile-transaction.js +238 -1
- package/lib/core/registry-controller.js +8 -0
- package/lib/core/registry.js +3 -3
- package/lib/core/settings-compat.js +4 -0
- package/lib/core/toggle.js +6 -1
- package/lib/core/versions.js +149 -25
- package/lib/host.js +2 -0
- package/lib/tools.js +137 -33
- package/package.json +1 -1
|
@@ -46,12 +46,13 @@ export const TX_HEAL_CODES = [
|
|
|
46
46
|
'B3_LAG_RETRY', 'B3_PACKUMENT_WARMED', 'BUILDS_ALLOWED',
|
|
47
47
|
'ROLLBACK_BYTES_RESTORED', 'ROLLBACK_CONVERGED_FROZEN', 'ROLLBACK_FALLBACK_REMOVED',
|
|
48
48
|
'ROLLBACK_VERIFY_FAILED', 'LIVE_DISABLED', 'LIVE_REENABLED', 'LIVE_REENABLE_FAILED',
|
|
49
|
-
'PATCH_ENTRIES_STRIPPED',
|
|
49
|
+
'PATCH_ENTRIES_STRIPPED', 'PATCH_MAPPING_RESTORED',
|
|
50
50
|
];
|
|
51
51
|
export const TX_FAILURE_CODES = [
|
|
52
52
|
'DEP_MISSING_AFTER_ADD', 'DEP_VERSION_MISMATCH', 'LOCKFILE_MISSING', 'LOCK_INTEGRITY_MISMATCH',
|
|
53
53
|
'GITHUB_SPEC_MISMATCH', 'ADD_RETRY_EXHAUSTED', 'ADD_FAILED', 'POST_MUTATION_CONVERGENCE_FAILED',
|
|
54
54
|
'NOT_INSTALLED', 'NOT_DSH_PLUGIN', 'REMOVE_FAILED', 'STILL_PRESENT_AFTER_REMOVE',
|
|
55
|
+
'COMPENSATE_EVIDENCE_MISMATCH', 'COMPENSATE_RESTORE_FAILED',
|
|
55
56
|
'ROLLBACK_FAILED', 'SNAPSHOT_FAILED', 'ABORTED', 'INTERNAL_ERROR',
|
|
56
57
|
'PROFILE_MANIFEST_UNREADABLE', 'PROFILE_MANIFEST_INVALID', 'PLUGIN_METADATA_UNREADABLE',
|
|
57
58
|
];
|
|
@@ -83,6 +84,8 @@ function prefixOf(kind, code) {
|
|
|
83
84
|
return '卸载失败';
|
|
84
85
|
if (kind === 'install-github')
|
|
85
86
|
return 'GitHub 安装失败';
|
|
87
|
+
if (kind === 'compensate-install')
|
|
88
|
+
return '补偿失败';
|
|
86
89
|
return '安装失败';
|
|
87
90
|
}
|
|
88
91
|
/** 失败结果的中文散文(唯一产地;三条 legacy 文案逐字钉住在 tests/profile-transaction.test.mjs)。 */
|
|
@@ -790,6 +793,238 @@ async function uninstall(req, d, snapshots) {
|
|
|
790
793
|
return await rollbackWithLiveReverse(req, d, failure, heal, snapshots, liveDisabled);
|
|
791
794
|
}
|
|
792
795
|
}
|
|
796
|
+
// ---------- compensate-install 门(M2 Task 3:专用补偿事务,四边界见 DESIGN §3) ----------
|
|
797
|
+
/** patchMapping 结构化双落点恢复:workspace(pnpm-workspace.yaml patchedDependencies)与 manifest(pnpm.patchedDependencies)。 */
|
|
798
|
+
async function restorePatchMapping(profileDir, mapping, heal) {
|
|
799
|
+
const wsFile = join(profileDir, 'pnpm-workspace.yaml');
|
|
800
|
+
if (mapping.workspace.length > 0) {
|
|
801
|
+
let text = '';
|
|
802
|
+
try {
|
|
803
|
+
text = await readFile(wsFile, 'utf8');
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
text = '';
|
|
807
|
+
}
|
|
808
|
+
const lines = text.replace(/\n*$/, '\n').split('\n');
|
|
809
|
+
if (!lines.some((l) => l.trim() === 'patchedDependencies:')) {
|
|
810
|
+
lines.push('patchedDependencies:');
|
|
811
|
+
}
|
|
812
|
+
for (const entry of mapping.workspace) {
|
|
813
|
+
const line = ` ${JSON.stringify(entry.key)}: ${entry.patchPath}`;
|
|
814
|
+
if (!lines.includes(line))
|
|
815
|
+
lines.push(line);
|
|
816
|
+
}
|
|
817
|
+
await atomicWriteFile(wsFile, Buffer.from(lines.join('\n'), 'utf8'));
|
|
818
|
+
heal.push({ code: 'PATCH_MAPPING_RESTORED', note: `已恢复 pnpm-workspace.yaml 补丁映射(${mapping.workspace.map((m) => m.key).join('、')})` });
|
|
819
|
+
}
|
|
820
|
+
if (mapping.manifest.length > 0) {
|
|
821
|
+
const mfFile = join(profileDir, 'package.json');
|
|
822
|
+
let doc = {};
|
|
823
|
+
try {
|
|
824
|
+
doc = JSON.parse(await readFile(mfFile, 'utf8'));
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
doc = {};
|
|
828
|
+
}
|
|
829
|
+
const pnpm = (doc.pnpm && typeof doc.pnpm === 'object' && !Array.isArray(doc.pnpm) ? doc.pnpm : {});
|
|
830
|
+
const patched = (pnpm.patchedDependencies && typeof pnpm.patchedDependencies === 'object' && !Array.isArray(pnpm.patchedDependencies)
|
|
831
|
+
? pnpm.patchedDependencies
|
|
832
|
+
: {});
|
|
833
|
+
for (const entry of mapping.manifest)
|
|
834
|
+
patched[entry.key] = entry.patchPath;
|
|
835
|
+
pnpm.patchedDependencies = patched;
|
|
836
|
+
doc.pnpm = pnpm;
|
|
837
|
+
await atomicWriteFile(mfFile, Buffer.from(JSON.stringify(doc, null, 2) + '\n', 'utf8'));
|
|
838
|
+
heal.push({ code: 'PATCH_MAPPING_RESTORED', note: `已恢复 package.json#pnpm.patchedDependencies(${mapping.manifest.map((m) => m.key).join('、')})` });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/** mapping 双落点复读确认(严格读写:任一落点写入后复读不符 → 失败)。 */
|
|
842
|
+
async function verifyPatchMapping(profileDir, mapping) {
|
|
843
|
+
if (mapping.workspace.length > 0) {
|
|
844
|
+
try {
|
|
845
|
+
const text = await readFile(join(profileDir, 'pnpm-workspace.yaml'), 'utf8');
|
|
846
|
+
for (const entry of mapping.workspace) {
|
|
847
|
+
if (!text.includes(JSON.stringify(entry.key)))
|
|
848
|
+
return `workspace 补丁映射复读缺失:${entry.key}`;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
return `pnpm-workspace.yaml 复读失败:${errText(err)}`;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (mapping.manifest.length > 0) {
|
|
856
|
+
try {
|
|
857
|
+
const doc = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
858
|
+
for (const entry of mapping.manifest) {
|
|
859
|
+
if (doc.pnpm?.patchedDependencies?.[entry.key] !== entry.patchPath)
|
|
860
|
+
return `manifest 补丁映射复读缺失:${entry.key}`;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
catch (err) {
|
|
864
|
+
return `package.json 复读失败:${errText(err)}`;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* 补偿门:validate(evidence 插队复验,零写入)→ 快照 → live-disable → strip → remove →
|
|
871
|
+
* verify gone → PriorUnion 分支(none 终 / mappingOnly 恢复 mapping+frozen / dependency 恢复 mapping→add→三重验证)。
|
|
872
|
+
* 无外部 signal(安装已 committed,清理不可被客户端断连取消)。
|
|
873
|
+
*/
|
|
874
|
+
async function compensateInstall(req, d) {
|
|
875
|
+
const heal = [];
|
|
876
|
+
const pkg = req.pkg;
|
|
877
|
+
// validate:evidence 实读比对(插队复验;失配 → rejected 零写入)
|
|
878
|
+
try {
|
|
879
|
+
const depsNow = await strictReadDeps(d);
|
|
880
|
+
const current = depsNow[pkg];
|
|
881
|
+
if (req.evidence.source === 'npm') {
|
|
882
|
+
if (current !== req.evidence.manifestSpec) {
|
|
883
|
+
return rejected({ code: 'COMPENSATE_EVIDENCE_MISMATCH', note: `manifest 依赖值已变化(现值 ${current ?? '缺失'},期望 ${req.evidence.manifestSpec});拒绝补偿` }, req.kind);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
else if (current !== req.evidence.pinnedSpec) {
|
|
887
|
+
return rejected({ code: 'COMPENSATE_EVIDENCE_MISMATCH', note: `manifest 依赖值已变化(现值 ${current ?? '缺失'},期望 ${req.evidence.pinnedSpec});拒绝补偿` }, req.kind);
|
|
888
|
+
}
|
|
889
|
+
if (req.evidence.source === 'npm') {
|
|
890
|
+
const lockText = await readFile(join(d.profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
891
|
+
const version = lockVersionOf(lockText, pkg) ?? req.evidence.resolvedVersion;
|
|
892
|
+
const integrity = readPnpmLockIntegrity(lockText, pkg, version);
|
|
893
|
+
if (integrity === null || integrity !== req.evidence.integrity) {
|
|
894
|
+
return rejected({ code: 'COMPENSATE_EVIDENCE_MISMATCH', note: 'lockfile integrity 与补偿证据不一致;拒绝补偿' }, req.kind);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
const lockText = await readFile(join(d.profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
899
|
+
const commit = lockCommitOf(lockText, pkg);
|
|
900
|
+
if (commit === null || commit !== req.evidence.lockCommitIdentity) {
|
|
901
|
+
return rejected({ code: 'COMPENSATE_EVIDENCE_MISMATCH', note: 'lockfile commit identity 与补偿证据不一致;拒绝补偿' }, req.kind);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
catch (err) {
|
|
906
|
+
if (err instanceof DomainFailure)
|
|
907
|
+
return rejected(err.failure, req.kind);
|
|
908
|
+
return rejected({ code: 'COMPENSATE_EVIDENCE_MISMATCH', note: `补偿证据复验读取失败:${errText(err)}` }, req.kind);
|
|
909
|
+
}
|
|
910
|
+
if (req.prior.kind === 'unavailable') {
|
|
911
|
+
return rejected({ code: 'COMPENSATE_EVIDENCE_MISMATCH', note: `prior 关联歧义,拒绝补偿:${req.prior.reason}` }, req.kind);
|
|
912
|
+
}
|
|
913
|
+
// 快照 → live-disable → strip → remove → verify gone(与卸载同序;live-disable 在快照后)
|
|
914
|
+
let snapshots;
|
|
915
|
+
try {
|
|
916
|
+
snapshots = await snapshotFiles(d.paths);
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
return rejected({ code: 'SNAPSHOT_FAILED', note: errText(err) }, req.kind);
|
|
920
|
+
}
|
|
921
|
+
let liveDisabled = false;
|
|
922
|
+
let orphanedPatchFiles = [];
|
|
923
|
+
try {
|
|
924
|
+
liveDisabled = await d.setLiveDisabled(pkg, true);
|
|
925
|
+
if (liveDisabled)
|
|
926
|
+
heal.push({ code: 'LIVE_DISABLED', note: '补偿前已将运行中的坏包界面下线' });
|
|
927
|
+
const patch = d.stripPatchedEntries(d.profileDir, pkg);
|
|
928
|
+
if (patch.changed)
|
|
929
|
+
heal.push({ code: 'PATCH_ENTRIES_STRIPPED', note: '已摘除该包的 pnpm 补丁条目' });
|
|
930
|
+
orphanedPatchFiles = patch.orphanedPatchFiles;
|
|
931
|
+
const rmOut = await d.runner.remove(pkg);
|
|
932
|
+
if (rmOut.class !== 'ok') {
|
|
933
|
+
return await rollbackAndConverge(d, req.kind, { code: 'REMOVE_FAILED', note: rmOut.output }, heal, snapshots);
|
|
934
|
+
}
|
|
935
|
+
const depsNow = await strictReadDeps(d);
|
|
936
|
+
if (pkg in depsNow) {
|
|
937
|
+
throw new DomainFailure({ code: 'STILL_PRESENT_AFTER_REMOVE', note: `补偿移除后仍存在 ${pkg}` });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
catch (err) {
|
|
941
|
+
const failure = err instanceof DomainFailure
|
|
942
|
+
? err.failure
|
|
943
|
+
: { code: 'INTERNAL_ERROR', note: errText(err) };
|
|
944
|
+
// rolled-back:坏包可能仍在 → needsRestart=true、restartSafe=false
|
|
945
|
+
const result = await rollbackAndConverge(d, req.kind, failure, heal, snapshots);
|
|
946
|
+
return result;
|
|
947
|
+
}
|
|
948
|
+
// PriorUnion 分支(v9/v10:显式分支,无通用「有 prior 就 add」路径)
|
|
949
|
+
const prior = req.prior;
|
|
950
|
+
if (prior.kind === 'none') {
|
|
951
|
+
return committed(req.kind, heal, `compensated:${pkg}`, { pkg, liveDisabled, orphanedPatchFiles });
|
|
952
|
+
}
|
|
953
|
+
const mapping = prior.kind === 'mappingOnly' ? prior.patchMapping : prior.state.patchMapping;
|
|
954
|
+
await restorePatchMapping(d.profileDir, mapping, heal);
|
|
955
|
+
const mappingBad = await verifyPatchMapping(d.profileDir, mapping);
|
|
956
|
+
if (mappingBad !== null) {
|
|
957
|
+
return manualRepair(req.kind, { code: 'COMPENSATE_RESTORE_FAILED', note: mappingBad }, heal, mappingBad, false);
|
|
958
|
+
}
|
|
959
|
+
if (prior.kind === 'mappingOnly') {
|
|
960
|
+
// 原依赖本不存在 → 恢复 mapping 后 frozen 验证(runner.add 零调用——add 会装回不存在的包)
|
|
961
|
+
const conv = await frozenConvergeLadder(d, heal);
|
|
962
|
+
if (conv.class !== 'ok') {
|
|
963
|
+
return manualRepair(req.kind, { code: 'POST_MUTATION_CONVERGENCE_FAILED', note: `mapping 恢复后 frozen 验证未通过:${conv.output}` }, heal, conv.output, false);
|
|
964
|
+
}
|
|
965
|
+
return committed(req.kind, heal, `compensated:${pkg}`, { pkg, liveDisabled, orphanedPatchFiles });
|
|
966
|
+
}
|
|
967
|
+
// dependency:恢复 mapping → add ladder → frozen 收敛 → 三重恢复验证
|
|
968
|
+
const priorState = prior.state;
|
|
969
|
+
if (!priorState.restorable) {
|
|
970
|
+
// 理论不可达(restorable:false 在 mutation 前已拒)——防御性 manual-repair
|
|
971
|
+
return manualRepair(req.kind, { code: 'COMPENSATE_RESTORE_FAILED', note: 'prior 不可自动恢复(restorable:false)' }, heal, 'restorable:false', false);
|
|
972
|
+
}
|
|
973
|
+
const addOut = await addWithLagRetry({ kind: 'install-npm', pkg, version: priorState.resolvedVersion ?? '', integrity: priorState.integrity ?? '' }, `${pkg}@${priorState.manifestSpec}`, d, heal);
|
|
974
|
+
if (addOut.class !== 'ok') {
|
|
975
|
+
const result = await rollbackAndConverge(d, req.kind, { code: 'ADD_FAILED', note: addOut.output }, heal, snapshots);
|
|
976
|
+
return result;
|
|
977
|
+
}
|
|
978
|
+
const conv = await frozenConvergeLadder(d, heal);
|
|
979
|
+
if (conv.class !== 'ok') {
|
|
980
|
+
return manualRepair(req.kind, { code: 'POST_MUTATION_CONVERGENCE_FAILED', note: `恢复后 frozen 收敛未通过:${conv.output}` }, heal, conv.output, false);
|
|
981
|
+
}
|
|
982
|
+
// 三重恢复验证:manifest spec / node_modules 实际 version / lock integrity
|
|
983
|
+
try {
|
|
984
|
+
const depsNow = await strictReadDeps(d);
|
|
985
|
+
const specNow = depsNow[pkg];
|
|
986
|
+
if (specNow === undefined)
|
|
987
|
+
throw new Error(`恢复后 manifest 缺少 ${pkg}`);
|
|
988
|
+
let versionNote = '';
|
|
989
|
+
if (priorState.sourceKind === 'npm') {
|
|
990
|
+
if (specNow !== priorState.manifestSpec) {
|
|
991
|
+
// CLI 书写规范化(^/~):接受规范化但明示,不称「完全恢复」
|
|
992
|
+
versionNote = `;已恢复旧 resolved version,spec 已规范化为 ${specNow}`;
|
|
993
|
+
}
|
|
994
|
+
const lockText = await readFile(join(d.profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
995
|
+
const version = lockVersionOf(lockText, pkg) ?? priorState.resolvedVersion ?? '';
|
|
996
|
+
const integrity = readPnpmLockIntegrity(lockText, pkg, version);
|
|
997
|
+
if (priorState.integrity && integrity !== priorState.integrity) {
|
|
998
|
+
throw new Error('lockfile integrity 与 prior 不一致');
|
|
999
|
+
}
|
|
1000
|
+
const nmPkg = JSON.parse(await readFile(join(d.profileDir, 'node_modules', pkg, 'package.json'), 'utf8'));
|
|
1001
|
+
if (priorState.resolvedVersion && nmPkg.version !== priorState.resolvedVersion) {
|
|
1002
|
+
throw new Error(`node_modules 实际版本(${String(nmPkg.version)})与 prior(${priorState.resolvedVersion})不一致`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return committed(req.kind, heal, `compensated:${pkg}`, {
|
|
1006
|
+
pkg,
|
|
1007
|
+
liveDisabled,
|
|
1008
|
+
orphanedPatchFiles,
|
|
1009
|
+
...(versionNote ? {} : {}),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
catch (err) {
|
|
1013
|
+
return manualRepair(req.kind, { code: 'COMPENSATE_RESTORE_FAILED', note: `三重恢复验证失败:${errText(err)}` }, heal, errText(err), false);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/** pnpm-lock packages 段中目标包的 resolution.version(粗粒度文本提取,供补偿验证)。 */
|
|
1017
|
+
function lockVersionOf(lockText, pkg) {
|
|
1018
|
+
const re = new RegExp(`'?(?:[^'\n]*node_modules/)?${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}@[^'\n]*':\s*\n[^}]*?version:\s*(\S+)`, 'm');
|
|
1019
|
+
const m = re.exec(lockText);
|
|
1020
|
+
return m ? m[1].replace(/'/g, '') : null;
|
|
1021
|
+
}
|
|
1022
|
+
/** pnpm-lock 目标包 resolution 的 commit(github tarball 无 integrity,以 commit identity 为准)。 */
|
|
1023
|
+
function lockCommitOf(lockText, pkg) {
|
|
1024
|
+
const re = new RegExp(`'?(?:[^'\n]*node_modules/)?${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}@[^'\n]*':\s*\n[^}]*?commit:\s*'?([0-9a-f]{40})`, 'm');
|
|
1025
|
+
const m = re.exec(lockText);
|
|
1026
|
+
return m ? m[1] : null;
|
|
1027
|
+
}
|
|
793
1028
|
// ---------- 入口:FIFO 互斥 + 分发 ----------
|
|
794
1029
|
/** 模块级 FIFO 互斥锁(进程内串行;skillhub install-lock 同款思路)。 */
|
|
795
1030
|
let txTail = Promise.resolve();
|
|
@@ -836,6 +1071,8 @@ async function executeTransaction(req, deps) {
|
|
|
836
1071
|
// 快照已取 → 照常走统一回滚(rollbackAndConverge 已 total,不会再递归逃逸)。
|
|
837
1072
|
try {
|
|
838
1073
|
switch (req.kind) {
|
|
1074
|
+
case 'compensate-install':
|
|
1075
|
+
return compensateInstall(req, d);
|
|
839
1076
|
case 'install-npm':
|
|
840
1077
|
return await installNpm(req, d, snapshots);
|
|
841
1078
|
case 'install-github':
|
|
@@ -127,6 +127,8 @@ export function createRegistryController(initial = {}) {
|
|
|
127
127
|
const source = store ? store.get() : initial;
|
|
128
128
|
config.timeoutMs = source.timeoutMs;
|
|
129
129
|
config.cacheTtlMin = source.cacheTtlMin;
|
|
130
|
+
// 社区字段初始值(两代 store 同型:legacy/forms 的 get() 都经 unwrapConfig 透传)
|
|
131
|
+
syncCommunityFields(source);
|
|
130
132
|
const raw = trimAddress(source.registryUrl);
|
|
131
133
|
const attempt = await loadRegistry({ ...config, registryUrl: raw }, { namespace: 'host' });
|
|
132
134
|
if (disposed || gen !== generation)
|
|
@@ -170,9 +172,15 @@ export function createRegistryController(initial = {}) {
|
|
|
170
172
|
}
|
|
171
173
|
return bootstrapPromise;
|
|
172
174
|
}
|
|
175
|
+
function syncCommunityFields(source) {
|
|
176
|
+
// 社区字段(M1 Task 6):live 生效——同 URL 分支前也要同步(开关/pin 变化不触发主清单重载)
|
|
177
|
+
config.communityCatalog = source.communityCatalog;
|
|
178
|
+
config.communityCatalogPin = source.communityCatalogPin;
|
|
179
|
+
}
|
|
173
180
|
function handleExternalWatch(next) {
|
|
174
181
|
if (disposed)
|
|
175
182
|
return Promise.resolve();
|
|
183
|
+
syncCommunityFields(next);
|
|
176
184
|
const raw = trimAddress(next.registryUrl);
|
|
177
185
|
if (lastSelfWrite !== null && raw === lastSelfWrite) {
|
|
178
186
|
lastSelfWrite = null;
|
package/lib/core/registry.js
CHANGED
|
@@ -9,7 +9,7 @@ import { isAbsolute, join, normalize } from 'node:path';
|
|
|
9
9
|
import { constants as fsConstants, mkdirSync } from 'node:fs';
|
|
10
10
|
import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
11
11
|
import { cacheDir } from './env.js';
|
|
12
|
-
import { decodeUtf8Fatal, fetchJsonLimitedMeta } from './httpx.js';
|
|
12
|
+
import { decodeUtf8Fatal, fetchJsonLimitedMeta, describeFetchFailure } from './httpx.js';
|
|
13
13
|
export const CATEGORIES = ['market', 'tools', 'ui', 'search', 'other'];
|
|
14
14
|
/** 容量上限:2 MiB 是原始 UTF-8 bytes(不是字符数);条目超限拒绝整份清单。 */
|
|
15
15
|
export const MAX_REGISTRY_BYTES = 2 * 1024 * 1024;
|
|
@@ -603,7 +603,7 @@ async function loadDefaultChain(cfg, opts, chain) {
|
|
|
603
603
|
};
|
|
604
604
|
}
|
|
605
605
|
catch (err) {
|
|
606
|
-
errors.push(
|
|
606
|
+
errors.push(describeFetchFailure({ label: candidate.source, url: candidate.url, err, elapsedMs: Date.now() - startedAt }));
|
|
607
607
|
}
|
|
608
608
|
}
|
|
609
609
|
const cached = await readCacheFile(chain.namespace, DEFAULT_CACHE_KEY);
|
|
@@ -671,7 +671,7 @@ async function loadCustomChain(address, cfg, opts, chain) {
|
|
|
671
671
|
};
|
|
672
672
|
}
|
|
673
673
|
catch (err) {
|
|
674
|
-
errors.push(
|
|
674
|
+
errors.push(describeFetchFailure({ label: source, url: address.normalized, err, elapsedMs: Date.now() - startedAt }));
|
|
675
675
|
}
|
|
676
676
|
if (chain.allowCacheFallback) {
|
|
677
677
|
const cached = await readCacheFile(chain.namespace, address.cacheKey);
|
|
@@ -39,6 +39,10 @@ export function unwrapConfig(source) {
|
|
|
39
39
|
out.timeoutMs = raw.timeoutMs;
|
|
40
40
|
if (typeof raw.cacheTtlMin === 'number')
|
|
41
41
|
out.cacheTtlMin = raw.cacheTtlMin;
|
|
42
|
+
if (typeof raw.communityCatalog === 'boolean')
|
|
43
|
+
out.communityCatalog = raw.communityCatalog;
|
|
44
|
+
if (typeof raw.communityCatalogPin === 'string')
|
|
45
|
+
out.communityCatalogPin = raw.communityCatalogPin;
|
|
42
46
|
}
|
|
43
47
|
return out;
|
|
44
48
|
}
|
package/lib/core/toggle.js
CHANGED
|
@@ -22,6 +22,7 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
|
22
22
|
import { listInstalledPlugins } from './installed.js';
|
|
23
23
|
import { isSingleRowPlugin, planRowOverride } from './patch-yaml.js';
|
|
24
24
|
import { PROTECTED_MODULES } from './enablement.js';
|
|
25
|
+
import { withMutationSession } from './market.js';
|
|
25
26
|
import { setLivePluginDisabled, loaderHost } from './live-plugin.js';
|
|
26
27
|
import { webProfileDir } from './env.js';
|
|
27
28
|
export class ToggleError extends Error {
|
|
@@ -52,7 +53,11 @@ async function readPatchText(io, path) {
|
|
|
52
53
|
throw error;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
export
|
|
56
|
+
export function togglePlugin(pkg, enabled, deps = {}) {
|
|
57
|
+
// M2 Task 3:toggle 纳入统一 mutation session(本体单点获取;自身 file lock 在 session 内层)
|
|
58
|
+
return withMutationSession(() => togglePluginLocked(pkg, enabled, deps));
|
|
59
|
+
}
|
|
60
|
+
async function togglePluginLocked(pkg, enabled, deps = {}) {
|
|
56
61
|
const key = String(pkg || '').trim();
|
|
57
62
|
// ① 保护门最先:纯名单检查(空 profile 也先于 not-installed 命中)
|
|
58
63
|
if (key === 'dsh-m' || PROTECTED_MODULES.includes(key)) {
|
package/lib/core/versions.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* GitHub:优先最新 release/tag(更新提示只跟稳定版走,不跟 main HEAD——中间提交可能不稳定);
|
|
5
5
|
* 未认证限额 60 次/小时,自用足够。
|
|
6
6
|
*/
|
|
7
|
+
import { gt } from 'semver';
|
|
7
8
|
import { fetchJsonLimited, HttpError } from './httpx.js';
|
|
8
9
|
/** GitHub 匿名限额(60 次/小时/IP)用尽时返回可读提示(含重置等待分钟数),否则 null。 */
|
|
9
10
|
function githubRateLimitMessage(err) {
|
|
@@ -77,46 +78,126 @@ export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry) {
|
|
|
77
78
|
tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
|
-
export async function githubTagSha(repo, tag, timeoutMs = 20_000, signal) {
|
|
81
|
+
export async function githubTagSha(repo, tag, timeoutMs = 20_000, signal, budget, apiBase = 'https://api.github.com') {
|
|
81
82
|
// commits/{ref} 会自动解引用 annotated tag,返回的才是可用于 #sha 锁定的 commit
|
|
82
|
-
const data = await fetchJsonLimited(
|
|
83
|
+
const data = await fetchJsonLimited(`${apiBase}/repos/${repo}/commits/${encodeURIComponent(tag)}`, {
|
|
83
84
|
timeoutMs,
|
|
84
85
|
signal,
|
|
85
86
|
headers: { accept: 'application/vnd.github+json' },
|
|
87
|
+
onRequest: wireReserve(budget),
|
|
86
88
|
});
|
|
87
89
|
const sha = typeof data.sha === 'string' ? data.sha : '';
|
|
88
90
|
if (!/^[0-9a-f]{40}$/.test(sha))
|
|
89
91
|
throw new Error(`GitHub 未返回有效 SHA: ${repo}@${tag}`);
|
|
90
92
|
return sha;
|
|
91
93
|
}
|
|
92
|
-
/**
|
|
93
|
-
export
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
/** 预算拒绝:该跳 fetch 不发出,错误可识别(latestError 呈现,不与其他网络错误混淆)。 */
|
|
95
|
+
export class GithubBudgetExhaustedError extends Error {
|
|
96
|
+
constructor() {
|
|
97
|
+
super('GitHub 更新检查预算已用尽,本次检查的剩余条目未完成');
|
|
98
|
+
this.name = 'GithubBudgetExhaustedError';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export const GITHUB_PASSIVE_PER_REQUEST_MAX = 25;
|
|
102
|
+
export const GITHUB_PASSIVE_HOURLY_MAX = 50;
|
|
103
|
+
const HOURLY_MS = 3_600_000;
|
|
104
|
+
/** 宿主级滚动 1 小时命中窗(仅被动探测路径计数;active 调用不经过 reserve 即不受限)。 */
|
|
105
|
+
const hourlyHits = [];
|
|
106
|
+
function hourlyReserve() {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
while (hourlyHits.length > 0 && now - hourlyHits[0] >= HOURLY_MS)
|
|
109
|
+
hourlyHits.shift();
|
|
110
|
+
if (hourlyHits.length >= GITHUB_PASSIVE_HOURLY_MAX)
|
|
111
|
+
return 'exhausted';
|
|
112
|
+
hourlyHits.push(now);
|
|
113
|
+
return 'ok';
|
|
114
|
+
}
|
|
115
|
+
/** 测试钩子:清空宿主滚动窗口。 */
|
|
116
|
+
export function _resetGithubHourlyWindowForTests() {
|
|
117
|
+
hourlyHits.length = 0;
|
|
118
|
+
}
|
|
119
|
+
/** 测试钩子:把窗口内全部命中回拨指定毫秒(模拟时间流逝,测过期滑出)。 */
|
|
120
|
+
export function _backdateGithubHourlyWindowForTests(ms) {
|
|
121
|
+
for (let i = 0; i < hourlyHits.length; i++)
|
|
122
|
+
hourlyHits[i] = (hourlyHits[i] ?? 0) - ms;
|
|
123
|
+
}
|
|
124
|
+
export function createGithubRequestBudget(opts = {}) {
|
|
125
|
+
let used = 0;
|
|
126
|
+
const max = Math.max(0, Math.floor(opts.perRequestMax ?? GITHUB_PASSIVE_PER_REQUEST_MAX));
|
|
127
|
+
return {
|
|
128
|
+
reserve() {
|
|
129
|
+
if (used >= max)
|
|
130
|
+
return 'exhausted';
|
|
131
|
+
if (hourlyReserve() === 'exhausted')
|
|
132
|
+
return 'exhausted';
|
|
133
|
+
used += 1;
|
|
134
|
+
return 'ok';
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** wire 层 reserve 钩子(httpx onRequest):拒绝即中止该跳且不发出请求;无预算 → undefined(零预算交互)。 */
|
|
139
|
+
function wireReserve(budget) {
|
|
140
|
+
if (!budget)
|
|
141
|
+
return undefined;
|
|
142
|
+
return () => {
|
|
143
|
+
if (budget.reserve() === 'exhausted')
|
|
144
|
+
throw new GithubBudgetExhaustedError();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const ghFlights = new Map();
|
|
148
|
+
function ghAbortError() {
|
|
149
|
+
const err = new Error('Aborted');
|
|
150
|
+
err.name = 'AbortError';
|
|
151
|
+
return err;
|
|
152
|
+
}
|
|
153
|
+
function raceWaiter(promise, timeoutMs, signal) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const timer = setTimeout(() => reject(new Error(`GitHub 请求超时(${timeoutMs}ms)`)), Math.max(1, timeoutMs));
|
|
156
|
+
const onAbort = () => reject(ghAbortError());
|
|
157
|
+
if (signal) {
|
|
158
|
+
if (signal.aborted) {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
reject(ghAbortError());
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
164
|
+
}
|
|
165
|
+
const done = (fn) => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
if (signal)
|
|
168
|
+
signal.removeEventListener('abort', onAbort);
|
|
169
|
+
fn();
|
|
170
|
+
};
|
|
171
|
+
promise.then((value) => done(() => resolve(value)), (err) => done(() => reject(err)));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** 共享 flight 的实际 fetch 序列:release 路径 1–2 个 wire 请求、fallback 路径逐次 reserve。 */
|
|
175
|
+
async function fetchGithubTag(repo, timeoutMs, signal, budget, apiBase = 'https://api.github.com') {
|
|
96
176
|
const headers = { accept: 'application/vnd.github+json' };
|
|
177
|
+
const reserve = wireReserve(budget);
|
|
97
178
|
try {
|
|
98
179
|
// 1) 最新 release(404 = 仓库从未发过 release → 回退 tags)
|
|
99
180
|
try {
|
|
100
|
-
const rel = await fetchJsonLimited(
|
|
181
|
+
const rel = await fetchJsonLimited(`${apiBase}/repos/${repo}/releases/latest`, { timeoutMs, signal, headers, onRequest: reserve });
|
|
101
182
|
const tag = typeof rel.tag_name === 'string' ? rel.tag_name.trim() : '';
|
|
102
183
|
if (tag)
|
|
103
|
-
return { tag, sha: await githubTagSha(repo, tag, timeoutMs, signal) };
|
|
184
|
+
return { tag, sha: await githubTagSha(repo, tag, timeoutMs, signal, budget, apiBase) };
|
|
104
185
|
}
|
|
105
186
|
catch (err) {
|
|
106
187
|
const status = err.status;
|
|
107
188
|
if (status !== 404)
|
|
108
189
|
throw err;
|
|
109
190
|
}
|
|
110
|
-
// 2) 回退:tags 列表(GitHub
|
|
111
|
-
|
|
191
|
+
// 2) 回退:tags 列表(GitHub 按创建时间倒序,首项即最新)——tag object sha 经 commits/{ref}
|
|
192
|
+
// 解引用为 commit sha(与 release 路径同语义,annotated tag 的 object sha 不可直接锁定)
|
|
193
|
+
const tags = await fetchJsonLimited(`${apiBase}/repos/${repo}/tags`, { timeoutMs, signal, headers, onRequest: reserve });
|
|
112
194
|
if (!Array.isArray(tags) || !tags.length)
|
|
113
195
|
throw new Error(`仓库没有任何 tag: ${repo}`);
|
|
114
196
|
const first = tags[0];
|
|
115
197
|
const name = typeof first.name === 'string' ? first.name : '';
|
|
116
|
-
|
|
117
|
-
if (!name || !/^[0-9a-f]{40}$/.test(sha))
|
|
198
|
+
if (!name)
|
|
118
199
|
throw new Error(`tag 信息无效: ${repo}`);
|
|
119
|
-
return { tag: name, sha };
|
|
200
|
+
return { tag: name, sha: await githubTagSha(repo, name, timeoutMs, signal, budget, apiBase) };
|
|
120
201
|
}
|
|
121
202
|
catch (err) {
|
|
122
203
|
const rateLimit = githubRateLimitMessage(err);
|
|
@@ -125,18 +206,61 @@ export async function githubLatestTag(repo, timeoutMs = 20_000, signal) {
|
|
|
125
206
|
throw err;
|
|
126
207
|
}
|
|
127
208
|
}
|
|
209
|
+
function releaseGhFlight(key, flight) {
|
|
210
|
+
flight.refs -= 1;
|
|
211
|
+
if (flight.refs <= 0) {
|
|
212
|
+
flight.ctrl.abort();
|
|
213
|
+
if (ghFlights.get(key) === flight)
|
|
214
|
+
ghFlights.delete(key);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* GitHub 来源的“最新稳定点”:优先最新 release(排除 draft/prerelease),无则回退 tags 列表首项。
|
|
219
|
+
* Q46 预算语义(M1 Task 5):
|
|
220
|
+
* - 同仓库 in-flight single-flight,key = 仓库 + 预算策略池(passive-budgeted / active-unbudgeted 互不 join);
|
|
221
|
+
* - 记账:flight 创建时绑定 leader 的预算对象(一次);reserve() 挂 httpx onRequest,
|
|
222
|
+
* 每个物理 outbound fetch(含重定向每一跳)各一次;joiner 不重复 reserve(join 零 wire 成本,
|
|
223
|
+
* 预算已耗尽的 waiter 可免费加入既有 flight);
|
|
224
|
+
* - waiter-scoped:每个 waiter 用自己的 timeoutMs/signal race 共享 Promise;任何 waiter 的
|
|
225
|
+
* signal 都不影响共享请求——最后一个 waiter 离开才 abort;
|
|
226
|
+
* - 无 budget 的调用(用户主动 install/upgrade/诊断)零预算交互,不受被动窗口限制。
|
|
227
|
+
*/
|
|
228
|
+
export async function githubLatestTag(repo, timeoutMs = 20_000, signal, budget, apiBase = 'https://api.github.com') {
|
|
229
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(repo))
|
|
230
|
+
throw new Error(`无效 GitHub 仓库: ${repo}`);
|
|
231
|
+
const key = `${budget ? 'passive' : 'active'}§${repo}`;
|
|
232
|
+
let flight = ghFlights.get(key);
|
|
233
|
+
if (!flight) {
|
|
234
|
+
const ctrl = new AbortController();
|
|
235
|
+
const created = { refs: 0, ctrl, promise: fetchGithubTag(repo, timeoutMs, ctrl.signal, budget, apiBase) };
|
|
236
|
+
created.promise.finally(() => {
|
|
237
|
+
if (ghFlights.get(key) === created && created.refs <= 0)
|
|
238
|
+
ghFlights.delete(key);
|
|
239
|
+
}).catch(() => undefined);
|
|
240
|
+
ghFlights.set(key, created);
|
|
241
|
+
flight = created;
|
|
242
|
+
}
|
|
243
|
+
flight.refs += 1;
|
|
244
|
+
try {
|
|
245
|
+
return await raceWaiter(flight.promise, timeoutMs, signal);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
releaseGhFlight(key, flight);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 版本前进比较(0.5.0 语义加固):标准 semver 前进比较——prerelease < 正式版、
|
|
253
|
+
* build metadata 不参与比较。旧自定义折叠实现把 `1.2.3-beta.1` 的数字尾段参与比较、
|
|
254
|
+
* 误判为大于 `1.2.3`,本实现以 semver.gt 修正。非法输入不抛、一律 false。
|
|
255
|
+
*/
|
|
128
256
|
export function isNewerVersion(candidate, current) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
|
138
|
-
if (diff !== 0)
|
|
139
|
-
return diff > 0;
|
|
257
|
+
// 只接受精确 semver(与收录语义一致:v 前缀/range/tag 不参与比较);非法输入不抛、一律 false
|
|
258
|
+
if (!EXACT_VERSION_RE.test(String(candidate ?? '')) || !EXACT_VERSION_RE.test(String(current ?? '')))
|
|
259
|
+
return false;
|
|
260
|
+
try {
|
|
261
|
+
return gt(String(candidate), String(current));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return false;
|
|
140
265
|
}
|
|
141
|
-
return false;
|
|
142
266
|
}
|
package/lib/host.js
CHANGED
|
@@ -24,6 +24,8 @@ export const Config = Schema.object({
|
|
|
24
24
|
registryUrl: live(Schema.string().description('registry 地址:空值使用默认官方清单;支持 HTTPS URL、loopback HTTP URL 或本机绝对路径/file://(整体覆盖默认清单,live 生效)')),
|
|
25
25
|
timeoutMs: live(Schema.number().default(20000).description('上游请求超时(毫秒)')),
|
|
26
26
|
cacheTtlMin: live(Schema.number().default(60).description('registry 缓存时长(分钟)')),
|
|
27
|
+
communityCatalog: live(Schema.boolean().default(true).description('社区清单:awesome-dsh-plugin 全量社区目录叠加进市场(只读,live 生效)')),
|
|
28
|
+
communityCatalogPin: live(Schema.string().description('社区目录版本锁定:精确 semver(如 2026.928.1),空值跟随 latest')),
|
|
27
29
|
});
|
|
28
30
|
export function apply(ctx, config) {
|
|
29
31
|
// 卸载前的 live-disable 依赖 loader(skillhub 同款)
|