dshmarket 1.66.7 → 1.66.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/presets.js +8 -4
- package/lib/routes.js +59 -13
- package/lib/sources.js +14 -0
- package/lib/types/presets.d.ts +6 -2
- package/lib/types/sources.d.ts +11 -0
- package/lib/types/updates.d.ts +8 -1
- package/lib/updates.js +23 -3
- package/package.json +1 -1
- package/src/presets.ts +15 -4
- package/src/routes.ts +58 -13
- package/src/sources.ts +15 -0
- package/src/updates.ts +22 -2
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ its own dsh: it may be older than the one `npm` would give you (#139).
|
|
|
41
41
|
- **Themes** — a dedicated tab for community themes and skins: install → active immediately, switch with one click (themes are mutually exclusive, your choice survives restarts), uninstall to revert
|
|
42
42
|
- **One-click install** — confirm the source, watch live progress; most plugins go live after a page refresh, no restart
|
|
43
43
|
- **Backup & restore** — export your profile's plugin list and configuration as readable JSON, import it on another machine, store it on WebDAV with daily auto-backup, or sync through a private GitHub Gist; restores **merge** (plugins installed after the backup are kept), validate before writing, and roll back on failure
|
|
44
|
-
- **Updates** — per-plugin update checks (npm version or pinned commit vs HEAD), one-click update, or update everything at once; the market updates itself the same way. Each row with an update pending carries a **What changed** link — the release notes, or the commits when the version cannot be aligned to one. A notice you are not acting on can be ignored for the rest of the boot instead of being dismissed again on every reload — scoped to the running host, so a restart brings the reminder back and ignoring it is never the same as turning it off
|
|
44
|
+
- **Updates** — per-plugin update checks (npm version or pinned commit vs HEAD), one-click update, or update everything at once; the market updates itself the same way. A plugin installed from an archive link is updated only through its own source: a catalog entry that merely shares the name is never offered as its update, so updating cannot silently replace it with a different plugin. Each row with an update pending carries a **What changed** link — the release notes, or the commits when the version cannot be aligned to one. A notice you are not acting on can be ignored for the rest of the boot instead of being dismissed again on every reload — scoped to the running host, so a restart brings the reminder back and ignoring it is never the same as turning it off
|
|
45
45
|
- **Resilient GitHub routes** — in the China download region, Git refs, README content, and avatars each keep their own fallback order. The market remembers the last working route, switches only after transport/HTTP/payload validation fails, and rejects proxy error pages disguised as HTTP 200. If every built-in route fails, **Settings → Plugins → Plugin configuration → GitHub acceleration** accepts one persistent custom HTTPS prefix; `DSHM_GITHUB_PROXY` remains the operator-owned override
|
|
46
46
|
- **Public update API** — plugin-owned settings pages can use the versioned, capability-gated [update API v1](UPDATE-API-V1.md) (beta) instead of copying package-manager logic or depending on private Market UI responses
|
|
47
47
|
- **Uninstall** — two-step confirm; plugins installed this session are removed live
|
package/README.zh.md
CHANGED
|
@@ -39,7 +39,7 @@ dsh plugin --profile web add dshmarket
|
|
|
39
39
|
- **主题**——独立主题页:装完立即生效,点一下切换(主题互斥、选择跨重启保留),卸载即恢复
|
|
40
40
|
- **一键安装**——确认来源,实时进度;多数插件刷新页面即可用,无需重启
|
|
41
41
|
- **备份与恢复**——把 profile 的插件清单与配置导出为可读 JSON,换机导入,存到 WebDAV 并每日自动备份,或通过私有 GitHub Gist 跨机器同步;恢复采用**合并**方式(备份之后新装的插件会保留),写入前校验、失败自动回滚
|
|
42
|
-
- **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD
|
|
42
|
+
- **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD),一键更新或全部更新;市场自己也走同一通道升级。从压缩包链接装的插件只认自己的来源:市场里恰好同名的另一个插件不会被当成它的更新,更新也不会悄悄把它换成另一个插件。每个有待更新的行上都有「更新内容」链接——有版本说明就是版本说明,对不上版本时给提交记录。暂时不处理的提醒可以**本次不再提醒**(本次 = 当前宿主进程),重启后提醒会回来——「忽略」永远不等于「关闭」
|
|
43
43
|
- **GitHub 多线路容错**——中国大陆下载区域会为 Git ref、README 与头像分别维护 fallback 顺序,记住上次可用线路,只在传输、HTTP 状态或响应内容校验失败后换线,并拒绝公共反代伪装成 HTTP 200 的 HTML 错误页。内置线路全部失效时,可到**设置 → 插件 → 插件配置 → GitHub 加速**填写一个持久化的自定义 HTTPS 前缀;运维设置的 `DSHM_GITHUB_PROXY` 始终优先
|
|
44
44
|
- **公共更新接口**——插件自己的设置页可调用带版本号、能力探测和回滚状态的[更新 API v1](UPDATE-API-V1.md)(beta),无需复制包管理逻辑,也不依赖市场 UI 的私有响应字段
|
|
45
45
|
- **卸载**——两步确认防误触;本次会话装的插件即点即卸
|
package/lib/presets.js
CHANGED
|
@@ -149,7 +149,9 @@ function presetMismatch(profileDir, bundleOrder) {
|
|
|
149
149
|
* reports the mismatch alongside the still-computable changes (relative
|
|
150
150
|
* order + enabled/disabled diffs over the intersection).
|
|
151
151
|
*/
|
|
152
|
-
export function previewPreset(profileDir, name
|
|
152
|
+
export function previewPreset(profileDir, name,
|
|
153
|
+
/** The host's own installation anchor, when it gave one (#781 review). */
|
|
154
|
+
dshInstallDir) {
|
|
153
155
|
if (typeof name !== 'string')
|
|
154
156
|
return { ok: false, error: 'invalid preset name / 组合名称无效' };
|
|
155
157
|
const preset = readPresets(profileDir).find(item => item.name === name);
|
|
@@ -166,7 +168,7 @@ export function previewPreset(profileDir, name) {
|
|
|
166
168
|
error: `preset is out of date — current profile differs: ${detail} / 组合已过期——当前 profile 的插件列表已变化:${detail}`,
|
|
167
169
|
};
|
|
168
170
|
}
|
|
169
|
-
const trial = trialValidate(profileDir, preset.bundleOrder);
|
|
171
|
+
const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir });
|
|
170
172
|
if (!trial.ok) {
|
|
171
173
|
const first = trial.errors[0];
|
|
172
174
|
return {
|
|
@@ -198,7 +200,9 @@ export function previewPreset(profileDir, name) {
|
|
|
198
200
|
* then write the bundle order and the disable list. The response carries the
|
|
199
201
|
* change preview so the UI can report exactly what moved.
|
|
200
202
|
*/
|
|
201
|
-
export function applyPreset(profileDir, name, maxSnapshots = DEFAULT_MAX_SNAPSHOTS
|
|
203
|
+
export function applyPreset(profileDir, name, maxSnapshots = DEFAULT_MAX_SNAPSHOTS,
|
|
204
|
+
/** The host's own installation anchor, when it gave one (#781 review). */
|
|
205
|
+
dshInstallDir) {
|
|
202
206
|
if (typeof name !== 'string')
|
|
203
207
|
return { ok: false, error: 'invalid preset name / 组合名称无效' };
|
|
204
208
|
const preset = readPresets(profileDir).find(item => item.name === name);
|
|
@@ -215,7 +219,7 @@ export function applyPreset(profileDir, name, maxSnapshots = DEFAULT_MAX_SNAPSHO
|
|
|
215
219
|
error: `preset is out of date — current profile differs: ${detail} / 组合已过期——当前 profile 的插件列表已变化:${detail}`,
|
|
216
220
|
};
|
|
217
221
|
}
|
|
218
|
-
const trial = trialValidate(profileDir, preset.bundleOrder);
|
|
222
|
+
const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir });
|
|
219
223
|
if (!trial.ok) {
|
|
220
224
|
const first = trial.errors[0];
|
|
221
225
|
logEvent('warn', 'preset', `apply "${name}" rejected by trial validation: ${first?.message ?? 'unknown'}`);
|
package/lib/routes.js
CHANGED
|
@@ -30,7 +30,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
|
|
|
30
30
|
import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
|
|
31
31
|
import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
|
|
32
32
|
import { trialValidate } from './trial.js';
|
|
33
|
-
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
|
|
33
|
+
import { catalogRepoKey, codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
|
|
34
34
|
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
35
35
|
import { classifyPnpmFailure } from './pnpm-compat.js';
|
|
36
36
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
|
|
@@ -228,6 +228,23 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
228
228
|
const analyzeActiveProfile = () => analyzeProfile(activeProfileDir, {
|
|
229
229
|
...(config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }),
|
|
230
230
|
});
|
|
231
|
+
/**
|
|
232
|
+
* The running installation's own anchor, in the shape `trialValidate` wants.
|
|
233
|
+
*
|
|
234
|
+
* The host hands the market this anchor (`profileContext.installAnchor`) and
|
|
235
|
+
* the diagnostics panel has always analysed with it, while every trial
|
|
236
|
+
* validation fell back to `findDshInstallDir()`'s filesystem probe. The two
|
|
237
|
+
* were therefore judging the SAME composition from DIFFERENT inputs, and
|
|
238
|
+
* disagreeing about it: the panel reported a plugin as fine while the update
|
|
239
|
+
* route answered 422 for the identical bundle order (#781 review). On a host
|
|
240
|
+
* whose own detection fails, the probe also resolves the official bundles out
|
|
241
|
+
* of whatever profile tree it can reach, which is how an install/update gets
|
|
242
|
+
* rolled back over a layer the running host never loaded.
|
|
243
|
+
*
|
|
244
|
+
* `undefined` keeps the old fallback behaviour, so this only ever adds
|
|
245
|
+
* information.
|
|
246
|
+
*/
|
|
247
|
+
const hostAnchorOption = config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir };
|
|
231
248
|
const persistentLogFile = join(activeProfileDir, '.dsh-market', 'log.ndjson');
|
|
232
249
|
const discoveryManifests = new DiscoveryManifestIndex(join(activeProfileDir, '.dsh-market', 'discovery-compatibility-v1.json'));
|
|
233
250
|
configurePersistentLog(persistentLogFile);
|
|
@@ -1384,6 +1401,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1384
1401
|
.filter(name => SELF_NAMES.has(name))
|
|
1385
1402
|
.map(name => [name, channel]));
|
|
1386
1403
|
const onlineSourceFor = new Map();
|
|
1404
|
+
// Which registry name — if any — an archive-URL install may be updated
|
|
1405
|
+
// through, keyed by the GitHub repo the archive came from (#768). Same
|
|
1406
|
+
// failure semantics as below: a failed registry load leaves the map
|
|
1407
|
+
// empty, and an empty map authorizes nothing.
|
|
1408
|
+
const catalogNpmByRepo = new Map();
|
|
1387
1409
|
try {
|
|
1388
1410
|
const registry = await loadRegistry();
|
|
1389
1411
|
for (const [name, spec] of Object.entries(installed)) {
|
|
@@ -1391,11 +1413,18 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1391
1413
|
if (source !== null)
|
|
1392
1414
|
onlineSourceFor.set(name, source);
|
|
1393
1415
|
}
|
|
1416
|
+
for (const plugin of registry.plugins) {
|
|
1417
|
+
if (plugin.npm === null || plugin.npm === undefined)
|
|
1418
|
+
continue;
|
|
1419
|
+
const repoKey = catalogRepoKey(plugin.url);
|
|
1420
|
+
if (repoKey !== null)
|
|
1421
|
+
catalogNpmByRepo.set(repoKey, plugin.npm);
|
|
1422
|
+
}
|
|
1394
1423
|
}
|
|
1395
1424
|
catch (error) {
|
|
1396
1425
|
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
1397
1426
|
}
|
|
1398
|
-
return { channelFor, onlineSourceFor };
|
|
1427
|
+
return { channelFor, onlineSourceFor, catalogNpmByRepo };
|
|
1399
1428
|
}
|
|
1400
1429
|
/**
|
|
1401
1430
|
* Pre-install host compatibility refusal, shared by the update route and
|
|
@@ -1514,8 +1543,8 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1514
1543
|
return;
|
|
1515
1544
|
}
|
|
1516
1545
|
try {
|
|
1517
|
-
const { channelFor, onlineSourceFor } = await updateCheckInputs();
|
|
1518
|
-
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor);
|
|
1546
|
+
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
|
|
1547
|
+
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo);
|
|
1519
1548
|
// `packages` carries the same objects the single-package endpoint
|
|
1520
1549
|
// returns, so one parser serves both. Only updatable ones: a badge
|
|
1521
1550
|
// wants the count, a panel wants the rows, and neither wants to
|
|
@@ -1561,8 +1590,8 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1561
1590
|
// The same inputs the market page builds, so a generation (#497)
|
|
1562
1591
|
// or a catalog-matched local package answers here with the
|
|
1563
1592
|
// release it can be compared against rather than with nothing.
|
|
1564
|
-
const { channelFor, onlineSourceFor } = await updateCheckInputs();
|
|
1565
|
-
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name];
|
|
1593
|
+
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
|
|
1594
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo))[name];
|
|
1566
1595
|
if (update === undefined) {
|
|
1567
1596
|
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
|
|
1568
1597
|
return;
|
|
@@ -2185,7 +2214,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2185
2214
|
return;
|
|
2186
2215
|
}
|
|
2187
2216
|
}
|
|
2188
|
-
const trial = trialValidate(activeProfileDir, order);
|
|
2217
|
+
const trial = trialValidate(activeProfileDir, order, hostAnchorOption);
|
|
2189
2218
|
if (!trial.ok) {
|
|
2190
2219
|
const first = trial.errors[0];
|
|
2191
2220
|
logEvent('warn', 'bundle-order', `rejected by trial validation: ${first?.message ?? 'unknown'}`);
|
|
@@ -2267,7 +2296,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2267
2296
|
// a concurrent pnpm run or another direct write must not interleave
|
|
2268
2297
|
// (issue #98 analysis: write-route mutual exclusion).
|
|
2269
2298
|
if (body.action === 'preview') {
|
|
2270
|
-
const previewed = previewPreset(activeProfileDir, name);
|
|
2299
|
+
const previewed = previewPreset(activeProfileDir, name, config.dshInstallDir);
|
|
2271
2300
|
sendJson(response, previewed.ok ? 200 : 422, previewed);
|
|
2272
2301
|
return;
|
|
2273
2302
|
}
|
|
@@ -2280,7 +2309,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2280
2309
|
}
|
|
2281
2310
|
case 'apply': {
|
|
2282
2311
|
pendingRollbacks.clear();
|
|
2283
|
-
const applied = applyPreset(activeProfileDir, name, maxSnapshots);
|
|
2312
|
+
const applied = applyPreset(activeProfileDir, name, maxSnapshots, config.dshInstallDir);
|
|
2284
2313
|
if (applied.ok) {
|
|
2285
2314
|
invalidateUpdates();
|
|
2286
2315
|
refreshMarketState();
|
|
@@ -3212,7 +3241,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3212
3241
|
}
|
|
3213
3242
|
try {
|
|
3214
3243
|
const force = (request.url ?? '').includes('force=1');
|
|
3215
|
-
const { channelFor, onlineSourceFor } = await updateCheckInputs();
|
|
3244
|
+
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
|
|
3216
3245
|
// Migration hints are this listing's own business: the market page
|
|
3217
3246
|
// is where "this could come from npm now" is offered, and no other
|
|
3218
3247
|
// caller acts on it.
|
|
@@ -3229,7 +3258,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3229
3258
|
catch (error) {
|
|
3230
3259
|
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
3231
3260
|
}
|
|
3232
|
-
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor);
|
|
3261
|
+
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo);
|
|
3233
3262
|
for (const [name, migration] of sourceMigrationFor) {
|
|
3234
3263
|
const status = updates[name];
|
|
3235
3264
|
if (status !== undefined)
|
|
@@ -3397,7 +3426,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3397
3426
|
return;
|
|
3398
3427
|
}
|
|
3399
3428
|
const stack = readBundleStack(activeProfileDir);
|
|
3400
|
-
const trial = trialValidate(activeProfileDir, stack.community);
|
|
3429
|
+
const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption);
|
|
3401
3430
|
if (!trial.ok) {
|
|
3402
3431
|
await failWithRollback(`迁移后的 profile 无法通过启动校验(${trial.errors[0]?.message ?? 'unknown'})。 / The migrated profile failed boot validation (${trial.errors[0]?.message ?? 'unknown'}).`);
|
|
3403
3432
|
return;
|
|
@@ -3617,6 +3646,23 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3617
3646
|
// classification chooses rollback mechanics; it must not weaken
|
|
3618
3647
|
// the existing registry target/downgrade validation.
|
|
3619
3648
|
const usesNpmUpdateTarget = !restore && !isGit;
|
|
3649
|
+
// #768: an archive URL carries no registry identity, so
|
|
3650
|
+
// name@<tag> would not update this install — it would replace it
|
|
3651
|
+
// with whatever package shares its name. Only proceed when a
|
|
3652
|
+
// catalog entry owns both the repo the archive came from and
|
|
3653
|
+
// this name; anything else keeps the plugin exactly as it is.
|
|
3654
|
+
if (usesNpmUpdateTarget && /^https?:/i.test(spec)) {
|
|
3655
|
+
const repo = lookupRepoFromUrl(spec);
|
|
3656
|
+
const repoKey = repo === null ? null : catalogRepoKey(repo);
|
|
3657
|
+
const { catalogNpmByRepo } = await updateCheckInputs();
|
|
3658
|
+
if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
|
|
3659
|
+
logEvent('warn', 'updates', `refused update for ${name}: no catalog entry owns both the archive's repo and this name (${spec})`);
|
|
3660
|
+
sendJson(response, 400, {
|
|
3661
|
+
error: `不能更新 ${name}:它是从一个压缩包链接装的,市场里的同名插件是另一个包,按名字更新会把它整个换掉。插件没有改动;想换成市场版本,先卸载它,再从市场安装。 / Cannot update ${name}: it was installed from an archive link, and the market's same-named plugin is a different package, so updating by name would replace it. Nothing was changed. To switch, uninstall it first, then install from the market.`,
|
|
3662
|
+
});
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3620
3666
|
// `@latest` was hardcoded, so a beta subscriber would have been
|
|
3621
3667
|
// told an update existed and then handed the stable build. The
|
|
3622
3668
|
// dist-tag has to follow the same setting the offer came from.
|
|
@@ -4229,7 +4275,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4229
4275
|
let trialError = null;
|
|
4230
4276
|
if (ok) {
|
|
4231
4277
|
const stack = readBundleStack(activeProfileDir);
|
|
4232
|
-
const trial = trialValidate(activeProfileDir, stack.community);
|
|
4278
|
+
const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption);
|
|
4233
4279
|
if (!trial.ok) {
|
|
4234
4280
|
ok = false;
|
|
4235
4281
|
// Name the LAYER, not only the message: the first error is
|
package/lib/sources.js
CHANGED
|
@@ -210,6 +210,20 @@ export function lookupRepoFromUrl(url) {
|
|
|
210
210
|
return `https://github.com/${releaseAsset[1]}`;
|
|
211
211
|
return null;
|
|
212
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Repo identity (`owner/repo`, lowercased) of any GitHub URL, ignoring the
|
|
215
|
+
* path after the repo — the plain repo page, monorepo subpaths, and the
|
|
216
|
+
* Release asset URLs `lookupRepoFromUrl` returns all fold to one key.
|
|
217
|
+
* Non-GitHub URLs return null.
|
|
218
|
+
*
|
|
219
|
+
* Update authorization for archive-URL installs (#768): the catalog entry
|
|
220
|
+
* has to own both the repo the archive came from and the registry name the
|
|
221
|
+
* update would use, and this is the repo side of that comparison.
|
|
222
|
+
*/
|
|
223
|
+
export function catalogRepoKey(url) {
|
|
224
|
+
const repo = /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/i.exec(url);
|
|
225
|
+
return repo === null ? null : `${repo[1]}/${repo[2]}`.toLowerCase();
|
|
226
|
+
}
|
|
213
227
|
/**
|
|
214
228
|
* Normalized identity of an install target, for comparing two targets that
|
|
215
229
|
* may be spelled differently — lowercased, matching `githubRepoIdentity`.
|
package/lib/types/presets.d.ts
CHANGED
|
@@ -77,7 +77,9 @@ export interface PresetMismatch {
|
|
|
77
77
|
* reports the mismatch alongside the still-computable changes (relative
|
|
78
78
|
* order + enabled/disabled diffs over the intersection).
|
|
79
79
|
*/
|
|
80
|
-
export declare function previewPreset(profileDir: string, name: unknown
|
|
80
|
+
export declare function previewPreset(profileDir: string, name: unknown,
|
|
81
|
+
/** The host's own installation anchor, when it gave one (#781 review). */
|
|
82
|
+
dshInstallDir?: string | null): PresetResult & {
|
|
81
83
|
changes?: PresetChange;
|
|
82
84
|
mismatch?: PresetMismatch;
|
|
83
85
|
};
|
|
@@ -87,4 +89,6 @@ export declare function previewPreset(profileDir: string, name: unknown): Preset
|
|
|
87
89
|
* then write the bundle order and the disable list. The response carries the
|
|
88
90
|
* change preview so the UI can report exactly what moved.
|
|
89
91
|
*/
|
|
90
|
-
export declare function applyPreset(profileDir: string, name: unknown, maxSnapshots?: number
|
|
92
|
+
export declare function applyPreset(profileDir: string, name: unknown, maxSnapshots?: number,
|
|
93
|
+
/** The host's own installation anchor, when it gave one (#781 review). */
|
|
94
|
+
dshInstallDir?: string | null): PresetApplyResult;
|
package/lib/types/sources.d.ts
CHANGED
|
@@ -64,6 +64,17 @@ export declare function codeloadTarball(repo: string, sha: string, proxy: string
|
|
|
64
64
|
* URL can serve different bytes at different times).
|
|
65
65
|
*/
|
|
66
66
|
export declare function lookupRepoFromUrl(url: string): string | null;
|
|
67
|
+
/**
|
|
68
|
+
* Repo identity (`owner/repo`, lowercased) of any GitHub URL, ignoring the
|
|
69
|
+
* path after the repo — the plain repo page, monorepo subpaths, and the
|
|
70
|
+
* Release asset URLs `lookupRepoFromUrl` returns all fold to one key.
|
|
71
|
+
* Non-GitHub URLs return null.
|
|
72
|
+
*
|
|
73
|
+
* Update authorization for archive-URL installs (#768): the catalog entry
|
|
74
|
+
* has to own both the repo the archive came from and the registry name the
|
|
75
|
+
* update would use, and this is the repo side of that comparison.
|
|
76
|
+
*/
|
|
77
|
+
export declare function catalogRepoKey(url: string): string | null;
|
|
67
78
|
/**
|
|
68
79
|
* Normalized identity of an install target, for comparing two targets that
|
|
69
80
|
* may be spelled differently — lowercased, matching `githubRepoIdentity`.
|
package/lib/types/updates.d.ts
CHANGED
|
@@ -134,4 +134,11 @@ channelFor?: ReadonlyMap<string, Channel>,
|
|
|
134
134
|
* the generations the desktop host links in (#497). Any other `link:` is
|
|
135
135
|
* a development workspace and is never opted into online updates.
|
|
136
136
|
*/
|
|
137
|
-
onlineSourceFor?: ReadonlyMap<string, string
|
|
137
|
+
onlineSourceFor?: ReadonlyMap<string, string>,
|
|
138
|
+
/**
|
|
139
|
+
* Registry names an archive-URL install may be checked against, keyed by
|
|
140
|
+
* the GitHub repo (`owner/repo`) the archive came from (#768). Without an
|
|
141
|
+
* entry that owns both the repo and the name, the npm fallback would
|
|
142
|
+
* compare the install with whatever package merely shares its name.
|
|
143
|
+
*/
|
|
144
|
+
catalogNpmByRepo?: ReadonlyMap<string, string>): Promise<Record<string, UpdateStatus>>;
|
package/lib/updates.js
CHANGED
|
@@ -8,7 +8,7 @@ import { resolveHeadCommit } from './accelerate.js';
|
|
|
8
8
|
import { marketFetch } from './net.js';
|
|
9
9
|
import { activeRegion } from './regions.js';
|
|
10
10
|
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
|
|
11
|
-
import { gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.js';
|
|
11
|
+
import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, repoOfTarget } from './sources.js';
|
|
12
12
|
const UPDATES_TTL_MS = 30 * 60 * 1000;
|
|
13
13
|
const GIT_REMOTE_HEAD_TIMEOUT_MS = 6000;
|
|
14
14
|
let updatesCache = null;
|
|
@@ -271,13 +271,20 @@ channelFor = new Map(),
|
|
|
271
271
|
* the generations the desktop host links in (#497). Any other `link:` is
|
|
272
272
|
* a development workspace and is never opted into online updates.
|
|
273
273
|
*/
|
|
274
|
-
onlineSourceFor = new Map()
|
|
274
|
+
onlineSourceFor = new Map(),
|
|
275
|
+
/**
|
|
276
|
+
* Registry names an archive-URL install may be checked against, keyed by
|
|
277
|
+
* the GitHub repo (`owner/repo`) the archive came from (#768). Without an
|
|
278
|
+
* entry that owns both the repo and the name, the npm fallback would
|
|
279
|
+
* compare the install with whatever package merely shares its name.
|
|
280
|
+
*/
|
|
281
|
+
catalogNpmByRepo = new Map()) {
|
|
275
282
|
const activeProfileDir = profileDir(profile, explicitDir);
|
|
276
283
|
// The channel is part of the key: switching to betas has to change the
|
|
277
284
|
// answer immediately, and a cache keyed on the profile alone would serve
|
|
278
285
|
// the stable verdict for the rest of the TTL — reading as "the setting did
|
|
279
286
|
// nothing".
|
|
280
|
-
const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}`;
|
|
287
|
+
const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`;
|
|
281
288
|
if (!force && updatesCache?.key === cacheKey && Date.now() - updatesCache.at < UPDATES_TTL_MS) {
|
|
282
289
|
return updatesCache.data;
|
|
283
290
|
}
|
|
@@ -379,6 +386,19 @@ onlineSourceFor = new Map()) {
|
|
|
379
386
|
};
|
|
380
387
|
}
|
|
381
388
|
else {
|
|
389
|
+
// A URL install (release archive, self-hosted tarball) has no
|
|
390
|
+
// registry identity of its own. Checking npm by name alone pairs it
|
|
391
|
+
// with whatever package shares that name (#768), so only proceed
|
|
392
|
+
// when a catalog entry owns both the repo the archive came from and
|
|
393
|
+
// this name; otherwise report no update rather than a false one.
|
|
394
|
+
if (/^https?:/i.test(spec)) {
|
|
395
|
+
const repo = lookupRepoFromUrl(spec);
|
|
396
|
+
const repoKey = repo === null ? null : catalogRepoKey(repo);
|
|
397
|
+
if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
|
|
398
|
+
result[name] = { kind: 'npm', version, current: version, latest: null, updateAvailable: false };
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
382
402
|
const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`)));
|
|
383
403
|
const stable = typeof meta.version === 'string' ? meta.version : null;
|
|
384
404
|
const channel = channelFor.get(name);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.66.
|
|
4
|
+
"version": "1.66.8",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/presets.ts
CHANGED
|
@@ -211,7 +211,12 @@ function presetMismatch(profileDir: string, bundleOrder: string[]): PresetMismat
|
|
|
211
211
|
* reports the mismatch alongside the still-computable changes (relative
|
|
212
212
|
* order + enabled/disabled diffs over the intersection).
|
|
213
213
|
*/
|
|
214
|
-
export function previewPreset(
|
|
214
|
+
export function previewPreset(
|
|
215
|
+
profileDir: string,
|
|
216
|
+
name: unknown,
|
|
217
|
+
/** The host's own installation anchor, when it gave one (#781 review). */
|
|
218
|
+
dshInstallDir?: string | null,
|
|
219
|
+
): PresetResult & { changes?: PresetChange; mismatch?: PresetMismatch } {
|
|
215
220
|
if (typeof name !== 'string') return { ok: false, error: 'invalid preset name / 组合名称无效' }
|
|
216
221
|
const preset = readPresets(profileDir).find(item => item.name === name)
|
|
217
222
|
if (preset === undefined) return { ok: false, error: 'preset not found / 组合不存在' }
|
|
@@ -228,7 +233,7 @@ export function previewPreset(profileDir: string, name: unknown): PresetResult &
|
|
|
228
233
|
}
|
|
229
234
|
}
|
|
230
235
|
|
|
231
|
-
const trial = trialValidate(profileDir, preset.bundleOrder)
|
|
236
|
+
const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir })
|
|
232
237
|
if (!trial.ok) {
|
|
233
238
|
const first = trial.errors[0]
|
|
234
239
|
return {
|
|
@@ -262,7 +267,13 @@ export function previewPreset(profileDir: string, name: unknown): PresetResult &
|
|
|
262
267
|
* then write the bundle order and the disable list. The response carries the
|
|
263
268
|
* change preview so the UI can report exactly what moved.
|
|
264
269
|
*/
|
|
265
|
-
export function applyPreset(
|
|
270
|
+
export function applyPreset(
|
|
271
|
+
profileDir: string,
|
|
272
|
+
name: unknown,
|
|
273
|
+
maxSnapshots: number = DEFAULT_MAX_SNAPSHOTS,
|
|
274
|
+
/** The host's own installation anchor, when it gave one (#781 review). */
|
|
275
|
+
dshInstallDir?: string | null,
|
|
276
|
+
): PresetApplyResult {
|
|
266
277
|
if (typeof name !== 'string') return { ok: false, error: 'invalid preset name / 组合名称无效' }
|
|
267
278
|
const preset = readPresets(profileDir).find(item => item.name === name)
|
|
268
279
|
if (preset === undefined) return { ok: false, error: 'preset not found / 组合不存在' }
|
|
@@ -279,7 +290,7 @@ export function applyPreset(profileDir: string, name: unknown, maxSnapshots: num
|
|
|
279
290
|
}
|
|
280
291
|
}
|
|
281
292
|
|
|
282
|
-
const trial = trialValidate(profileDir, preset.bundleOrder)
|
|
293
|
+
const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir })
|
|
283
294
|
if (!trial.ok) {
|
|
284
295
|
const first = trial.errors[0]
|
|
285
296
|
logEvent('warn', 'preset', `apply "${name}" rejected by trial validation: ${first?.message ?? 'unknown'}`)
|
package/src/routes.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
|
|
|
38
38
|
import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.ts'
|
|
39
39
|
import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.ts'
|
|
40
40
|
import { trialValidate } from './trial.ts'
|
|
41
|
-
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
|
|
41
|
+
import { catalogRepoKey, codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
|
|
42
42
|
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
43
43
|
import { classifyPnpmFailure } from './pnpm-compat.ts'
|
|
44
44
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
|
|
@@ -326,6 +326,23 @@ export function mountMarketRoutes(
|
|
|
326
326
|
const analyzeActiveProfile = () => analyzeProfile(activeProfileDir, {
|
|
327
327
|
...(config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }),
|
|
328
328
|
})
|
|
329
|
+
/**
|
|
330
|
+
* The running installation's own anchor, in the shape `trialValidate` wants.
|
|
331
|
+
*
|
|
332
|
+
* The host hands the market this anchor (`profileContext.installAnchor`) and
|
|
333
|
+
* the diagnostics panel has always analysed with it, while every trial
|
|
334
|
+
* validation fell back to `findDshInstallDir()`'s filesystem probe. The two
|
|
335
|
+
* were therefore judging the SAME composition from DIFFERENT inputs, and
|
|
336
|
+
* disagreeing about it: the panel reported a plugin as fine while the update
|
|
337
|
+
* route answered 422 for the identical bundle order (#781 review). On a host
|
|
338
|
+
* whose own detection fails, the probe also resolves the official bundles out
|
|
339
|
+
* of whatever profile tree it can reach, which is how an install/update gets
|
|
340
|
+
* rolled back over a layer the running host never loaded.
|
|
341
|
+
*
|
|
342
|
+
* `undefined` keeps the old fallback behaviour, so this only ever adds
|
|
343
|
+
* information.
|
|
344
|
+
*/
|
|
345
|
+
const hostAnchorOption = config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }
|
|
329
346
|
const persistentLogFile = join(activeProfileDir, '.dsh-market', 'log.ndjson')
|
|
330
347
|
const discoveryManifests = new DiscoveryManifestIndex(
|
|
331
348
|
join(activeProfileDir, '.dsh-market', 'discovery-compatibility-v1.json'),
|
|
@@ -1528,6 +1545,7 @@ export function mountMarketRoutes(
|
|
|
1528
1545
|
async function updateCheckInputs(): Promise<{
|
|
1529
1546
|
channelFor: Map<string, Channel>
|
|
1530
1547
|
onlineSourceFor: Map<string, string>
|
|
1548
|
+
catalogNpmByRepo: Map<string, string>
|
|
1531
1549
|
}> {
|
|
1532
1550
|
// Only the market itself follows the channel setting (see
|
|
1533
1551
|
// MarketSettings.channel): a user opting into betas is volunteering to
|
|
@@ -1541,16 +1559,26 @@ export function mountMarketRoutes(
|
|
|
1541
1559
|
.map(name => [name, channel] as const),
|
|
1542
1560
|
)
|
|
1543
1561
|
const onlineSourceFor = new Map<string, string>()
|
|
1562
|
+
// Which registry name — if any — an archive-URL install may be updated
|
|
1563
|
+
// through, keyed by the GitHub repo the archive came from (#768). Same
|
|
1564
|
+
// failure semantics as below: a failed registry load leaves the map
|
|
1565
|
+
// empty, and an empty map authorizes nothing.
|
|
1566
|
+
const catalogNpmByRepo = new Map<string, string>()
|
|
1544
1567
|
try {
|
|
1545
1568
|
const registry = await loadRegistry()
|
|
1546
1569
|
for (const [name, spec] of Object.entries(installed)) {
|
|
1547
1570
|
const source = onlineSourceOf(registry.plugins, name, spec)
|
|
1548
1571
|
if (source !== null) onlineSourceFor.set(name, source)
|
|
1549
1572
|
}
|
|
1573
|
+
for (const plugin of registry.plugins) {
|
|
1574
|
+
if (plugin.npm === null || plugin.npm === undefined) continue
|
|
1575
|
+
const repoKey = catalogRepoKey(plugin.url)
|
|
1576
|
+
if (repoKey !== null) catalogNpmByRepo.set(repoKey, plugin.npm)
|
|
1577
|
+
}
|
|
1550
1578
|
} catch (error) {
|
|
1551
1579
|
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
1552
1580
|
}
|
|
1553
|
-
return { channelFor, onlineSourceFor }
|
|
1581
|
+
return { channelFor, onlineSourceFor, catalogNpmByRepo }
|
|
1554
1582
|
}
|
|
1555
1583
|
|
|
1556
1584
|
/**
|
|
@@ -1681,8 +1709,8 @@ export function mountMarketRoutes(
|
|
|
1681
1709
|
return
|
|
1682
1710
|
}
|
|
1683
1711
|
try {
|
|
1684
|
-
const { channelFor, onlineSourceFor } = await updateCheckInputs()
|
|
1685
|
-
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor)
|
|
1712
|
+
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
|
|
1713
|
+
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo)
|
|
1686
1714
|
// `packages` carries the same objects the single-package endpoint
|
|
1687
1715
|
// returns, so one parser serves both. Only updatable ones: a badge
|
|
1688
1716
|
// wants the count, a panel wants the rows, and neither wants to
|
|
@@ -1728,8 +1756,8 @@ export function mountMarketRoutes(
|
|
|
1728
1756
|
// The same inputs the market page builds, so a generation (#497)
|
|
1729
1757
|
// or a catalog-matched local package answers here with the
|
|
1730
1758
|
// release it can be compared against rather than with nothing.
|
|
1731
|
-
const { channelFor, onlineSourceFor } = await updateCheckInputs()
|
|
1732
|
-
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name]
|
|
1759
|
+
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
|
|
1760
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo))[name]
|
|
1733
1761
|
if (update === undefined) {
|
|
1734
1762
|
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' })
|
|
1735
1763
|
return
|
|
@@ -2363,7 +2391,7 @@ export function mountMarketRoutes(
|
|
|
2363
2391
|
return
|
|
2364
2392
|
}
|
|
2365
2393
|
}
|
|
2366
|
-
const trial = trialValidate(activeProfileDir, order)
|
|
2394
|
+
const trial = trialValidate(activeProfileDir, order, hostAnchorOption)
|
|
2367
2395
|
if (!trial.ok) {
|
|
2368
2396
|
const first = trial.errors[0]
|
|
2369
2397
|
logEvent('warn', 'bundle-order', `rejected by trial validation: ${first?.message ?? 'unknown'}`)
|
|
@@ -2444,7 +2472,7 @@ export function mountMarketRoutes(
|
|
|
2444
2472
|
// a concurrent pnpm run or another direct write must not interleave
|
|
2445
2473
|
// (issue #98 analysis: write-route mutual exclusion).
|
|
2446
2474
|
if (body.action === 'preview') {
|
|
2447
|
-
const previewed = previewPreset(activeProfileDir, name)
|
|
2475
|
+
const previewed = previewPreset(activeProfileDir, name, config.dshInstallDir)
|
|
2448
2476
|
sendJson(response, previewed.ok ? 200 : 422, previewed)
|
|
2449
2477
|
return
|
|
2450
2478
|
}
|
|
@@ -2457,7 +2485,7 @@ export function mountMarketRoutes(
|
|
|
2457
2485
|
}
|
|
2458
2486
|
case 'apply': {
|
|
2459
2487
|
pendingRollbacks.clear()
|
|
2460
|
-
const applied = applyPreset(activeProfileDir, name, maxSnapshots)
|
|
2488
|
+
const applied = applyPreset(activeProfileDir, name, maxSnapshots, config.dshInstallDir)
|
|
2461
2489
|
if (applied.ok) {
|
|
2462
2490
|
invalidateUpdates()
|
|
2463
2491
|
refreshMarketState()
|
|
@@ -3375,7 +3403,7 @@ export function mountMarketRoutes(
|
|
|
3375
3403
|
}
|
|
3376
3404
|
try {
|
|
3377
3405
|
const force = (request.url ?? '').includes('force=1')
|
|
3378
|
-
const { channelFor, onlineSourceFor } = await updateCheckInputs()
|
|
3406
|
+
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
|
|
3379
3407
|
// Migration hints are this listing's own business: the market page
|
|
3380
3408
|
// is where "this could come from npm now" is offered, and no other
|
|
3381
3409
|
// caller acts on it.
|
|
@@ -3390,7 +3418,7 @@ export function mountMarketRoutes(
|
|
|
3390
3418
|
} catch (error) {
|
|
3391
3419
|
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
3392
3420
|
}
|
|
3393
|
-
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor)
|
|
3421
|
+
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo)
|
|
3394
3422
|
for (const [name, migration] of sourceMigrationFor) {
|
|
3395
3423
|
const status = updates[name]
|
|
3396
3424
|
if (status !== undefined) updates[name] = { ...status, sourceMigration: migration }
|
|
@@ -3571,7 +3599,7 @@ sendJson(response, 200, { updates })
|
|
|
3571
3599
|
}
|
|
3572
3600
|
|
|
3573
3601
|
const stack = readBundleStack(activeProfileDir)
|
|
3574
|
-
const trial = trialValidate(activeProfileDir, stack.community)
|
|
3602
|
+
const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption)
|
|
3575
3603
|
if (!trial.ok) {
|
|
3576
3604
|
await failWithRollback(`迁移后的 profile 无法通过启动校验(${trial.errors[0]?.message ?? 'unknown'})。 / The migrated profile failed boot validation (${trial.errors[0]?.message ?? 'unknown'}).`)
|
|
3577
3605
|
return
|
|
@@ -3788,6 +3816,23 @@ sendJson(response, 200, { updates })
|
|
|
3788
3816
|
// classification chooses rollback mechanics; it must not weaken
|
|
3789
3817
|
// the existing registry target/downgrade validation.
|
|
3790
3818
|
const usesNpmUpdateTarget = !restore && !isGit
|
|
3819
|
+
// #768: an archive URL carries no registry identity, so
|
|
3820
|
+
// name@<tag> would not update this install — it would replace it
|
|
3821
|
+
// with whatever package shares its name. Only proceed when a
|
|
3822
|
+
// catalog entry owns both the repo the archive came from and
|
|
3823
|
+
// this name; anything else keeps the plugin exactly as it is.
|
|
3824
|
+
if (usesNpmUpdateTarget && /^https?:/i.test(spec)) {
|
|
3825
|
+
const repo = lookupRepoFromUrl(spec)
|
|
3826
|
+
const repoKey = repo === null ? null : catalogRepoKey(repo)
|
|
3827
|
+
const { catalogNpmByRepo } = await updateCheckInputs()
|
|
3828
|
+
if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
|
|
3829
|
+
logEvent('warn', 'updates', `refused update for ${name}: no catalog entry owns both the archive's repo and this name (${spec})`)
|
|
3830
|
+
sendJson(response, 400, {
|
|
3831
|
+
error: `不能更新 ${name}:它是从一个压缩包链接装的,市场里的同名插件是另一个包,按名字更新会把它整个换掉。插件没有改动;想换成市场版本,先卸载它,再从市场安装。 / Cannot update ${name}: it was installed from an archive link, and the market's same-named plugin is a different package, so updating by name would replace it. Nothing was changed. To switch, uninstall it first, then install from the market.`,
|
|
3832
|
+
})
|
|
3833
|
+
return
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3791
3836
|
// `@latest` was hardcoded, so a beta subscriber would have been
|
|
3792
3837
|
// told an update existed and then handed the stable build. The
|
|
3793
3838
|
// dist-tag has to follow the same setting the offer came from.
|
|
@@ -4399,7 +4444,7 @@ sendJson(response, 200, { updates })
|
|
|
4399
4444
|
let trialError: string | null = null
|
|
4400
4445
|
if (ok) {
|
|
4401
4446
|
const stack = readBundleStack(activeProfileDir)
|
|
4402
|
-
const trial = trialValidate(activeProfileDir, stack.community)
|
|
4447
|
+
const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption)
|
|
4403
4448
|
if (!trial.ok) {
|
|
4404
4449
|
ok = false
|
|
4405
4450
|
// Name the LAYER, not only the message: the first error is
|
package/src/sources.ts
CHANGED
|
@@ -208,6 +208,21 @@ export function lookupRepoFromUrl(url: string): string | null {
|
|
|
208
208
|
return null
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Repo identity (`owner/repo`, lowercased) of any GitHub URL, ignoring the
|
|
213
|
+
* path after the repo — the plain repo page, monorepo subpaths, and the
|
|
214
|
+
* Release asset URLs `lookupRepoFromUrl` returns all fold to one key.
|
|
215
|
+
* Non-GitHub URLs return null.
|
|
216
|
+
*
|
|
217
|
+
* Update authorization for archive-URL installs (#768): the catalog entry
|
|
218
|
+
* has to own both the repo the archive came from and the registry name the
|
|
219
|
+
* update would use, and this is the repo side of that comparison.
|
|
220
|
+
*/
|
|
221
|
+
export function catalogRepoKey(url: string): string | null {
|
|
222
|
+
const repo = /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/i.exec(url)
|
|
223
|
+
return repo === null ? null : `${repo[1]}/${repo[2]}`.toLowerCase()
|
|
224
|
+
}
|
|
225
|
+
|
|
211
226
|
/**
|
|
212
227
|
* Normalized identity of an install target, for comparing two targets that
|
|
213
228
|
* may be spelled differently — lowercased, matching `githubRepoIdentity`.
|
package/src/updates.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { resolveHeadCommit } from './accelerate.ts'
|
|
|
9
9
|
import { marketFetch } from './net.ts'
|
|
10
10
|
import { activeRegion } from './regions.ts'
|
|
11
11
|
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.ts'
|
|
12
|
-
import { gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.ts'
|
|
12
|
+
import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, repoOfTarget } from './sources.ts'
|
|
13
13
|
|
|
14
14
|
export interface UpdateStatus {
|
|
15
15
|
/**
|
|
@@ -317,13 +317,20 @@ export async function checkUpdates(
|
|
|
317
317
|
* a development workspace and is never opted into online updates.
|
|
318
318
|
*/
|
|
319
319
|
onlineSourceFor: ReadonlyMap<string, string> = new Map(),
|
|
320
|
+
/**
|
|
321
|
+
* Registry names an archive-URL install may be checked against, keyed by
|
|
322
|
+
* the GitHub repo (`owner/repo`) the archive came from (#768). Without an
|
|
323
|
+
* entry that owns both the repo and the name, the npm fallback would
|
|
324
|
+
* compare the install with whatever package merely shares its name.
|
|
325
|
+
*/
|
|
326
|
+
catalogNpmByRepo: ReadonlyMap<string, string> = new Map(),
|
|
320
327
|
): Promise<Record<string, UpdateStatus>> {
|
|
321
328
|
const activeProfileDir = profileDir(profile, explicitDir)
|
|
322
329
|
// The channel is part of the key: switching to betas has to change the
|
|
323
330
|
// answer immediately, and a cache keyed on the profile alone would serve
|
|
324
331
|
// the stable verdict for the rest of the TTL — reading as "the setting did
|
|
325
332
|
// nothing".
|
|
326
|
-
const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}`
|
|
333
|
+
const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`
|
|
327
334
|
if (!force && updatesCache?.key === cacheKey && Date.now() - updatesCache.at < UPDATES_TTL_MS) {
|
|
328
335
|
return updatesCache.data
|
|
329
336
|
}
|
|
@@ -425,6 +432,19 @@ export async function checkUpdates(
|
|
|
425
432
|
updateAvailable: current !== null && latest !== null && current !== latest,
|
|
426
433
|
}
|
|
427
434
|
} else {
|
|
435
|
+
// A URL install (release archive, self-hosted tarball) has no
|
|
436
|
+
// registry identity of its own. Checking npm by name alone pairs it
|
|
437
|
+
// with whatever package shares that name (#768), so only proceed
|
|
438
|
+
// when a catalog entry owns both the repo the archive came from and
|
|
439
|
+
// this name; otherwise report no update rather than a false one.
|
|
440
|
+
if (/^https?:/i.test(spec)) {
|
|
441
|
+
const repo = lookupRepoFromUrl(spec)
|
|
442
|
+
const repoKey = repo === null ? null : catalogRepoKey(repo)
|
|
443
|
+
if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
|
|
444
|
+
result[name] = { kind: 'npm', version, current: version, latest: null, updateAvailable: false }
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
}
|
|
428
448
|
const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`))) as { version?: string }
|
|
429
449
|
const stable = typeof meta.version === 'string' ? meta.version : null
|
|
430
450
|
const channel = channelFor.get(name)
|